#!/usr/bin/env python3
"""Generate every page of the sgit.ai vault site from a shared template.

Release process (see admin/index.html):
  1. bump SITE_VERSION below (v0.1.n, n increases on every push)
  2. add a row to VERSION_LOG
  3. run this script, run the validation suite, git commit + push (or ./admin/build/release.sh)
"""
import os
import re
import json
import html
import datetime
from collections import Counter

from content import Content_Loader, Content_Error
from html.parser import HTMLParser

SITE_VERSION = 'v0.7.39'
BUILD_DATE = '2026-08-15'

def find_vault_root():
    d = os.path.dirname(os.path.abspath(__file__))
    while not os.path.exists(os.path.join(d, 'app.json')):
        parent = os.path.dirname(d)
        if parent == d:
            raise SystemExit('vault root not found (no app.json above this script)')
        d = parent
    return d

VERSION_LOG = [
    ('v0.7.39', '2026-10-10', 'this release',
     "THE ZIGZAG. Pay after you read gains 'The zigzag: innovate, leverage, commoditise': Simon Wardley's ILC drawn for "
     "this project, from sgit's first release on 4 March 2026 (sg-send-cli 0.3.0, 88 sgit-ai releases to 0.20.0) through "
     "vaults that open as apps (first published 16 August, 53 now), Claude sessions with a repository and a vault, websites "
     "built and released by agents (sgit.ai v0.1.1 on 11 August), the newsrooms and the one-cent wallet (by 27 September) "
     "and SG Meter (10 October), to the reader at the top, for whom it just works. Each layer matured until it was "
     "invisible, which is what made the next possible. Two figures: the zigzag, and an animation that builds it one layer "
     "at a time."),
    ('v0.7.38', '2026-10-10', 'git 3f07af13',
     "A LINK TO A PERSONA: THE BRIEF, AS AN ARTICLE. Send a CISO to the newsroom as a CISO, not as nobody. Personas move "
     "out of the site's code into an encrypted vault kept by the newsroom's agents and opened in the browser with a "
     "published read key, so a persona is added or updated with a vault commit, not a site release. Three kinds of link: "
     "a page of its own for the few everyone uses (/persona/ciso/), a URL fragment for any number more (/persona/#dpo, "
     "never sent to a server, RFC 3986 section 3.5), and a private persona in a vault of its own for one person. The "
     "reader can follow the persona as it is kept up to date, make it their own (a fork), or remove it. A persona keeper "
     "role on the desk, the vault layout and persona schema, consent (public personas are roles; personas about people "
     "stay private), risks, and a six-step build plan for newsroom.sgit.ai. Prior art checked against its sources: "
     "Netflix profiles (August 2013, five per account), Bluesky starter packs (June 2024, 150 people and three feeds), "
     "Mastodon follow packs, Apple News and Brave News on personalisation on the device.",),
    ('v0.7.37', '2026-10-10', 'git 4e1d2346',
     "THE NEWSROOM MOVES TO NEWSROOM.SGIT.AI: THE BRIEF. A new brief in docs/briefs for the session working on "
     "newsroom.sgit.ai: what moves (65 articles with their graphs, versions and views, the front page, the desk, the "
     "newsletter, subscribe, the reader account and SG Meter), where each piece is in the source (functions of "
     "build_pages.py, content.py, newsroom.py, desk.py), the same paths on the new site and the one collision (its "
     "/newsroom/ design page moves to /design/newsroom.html), five phases with acceptance checks, reader data moved "
     "between origins through a URL fragment that never reaches a server, what not to break, the decisions that are "
     "Dinis's, and the open items. With a 1.6 MB bundle (239 source and content files; 485 images listed by URL). Also "
     "ships SG Meter v1.3.1: Send on the share page stays disabled until the consent box is ticked.",),
    ('v0.7.36', '2026-10-10', 'git 75b94297',
     "PAY AFTER YOU READ. A new article introducing everything the reading meter gained on 10 October, as one working "
     "model: a price for every page paid by the share read, a balance that goes below zero without blocking, the reader "
     "card whose usefulness rating sets the price (free to double), personas and a newsroom for each, the reading account, "
     "sending your reading encrypted for a designed front page, article versions and a £5 Stripe top-up. It sets paying "
     "after the value against Leanpub's pay-before model, with the evidence on pay what you want (Gneezy and others, Kim, "
     "Natter and Spann, Riener and Traxler) and Blendle's refunds; the user-first decisions behind each feature; how it "
     "grew one release at a time from pt.newsroom.sgit.ai's wallet, as an animated Wardley map; why it could go this fast "
     "(gates, versioned components, a browser test, git, reused lanes, parallel sessions); an honest list of what is still "
     "missing; and an offer to sites with traffic to test it. Eight figures: the animated map, the final map, the "
     "afternoon's timeline from git, the decisions, and four screenshots from a fresh browser."),
    ('v0.7.35', '2026-10-10', 'git d2c00d23',
     "THE KEY HANDOVER PROMPT, IN FULL. Where the vault keys live gains 'The prompt, in full': the two parts given to a "
     "Claude session that holds a vault key to send to the registry. First the reason (a vault key is address, "
     "credential and encryption key with no reset; transcripts are durable and two keys were already rotated because "
     "they were displayed; verify the fingerprint sha256:20b7bb9dbac7df90 from the published PEM; the token is "
     "write-only; and the agent may refuse and hand the key over another way), then the instruction. The inbox vault id "
     "and append token are shown as placeholders, because each sender gets its own token and a published one would let "
     "anyone write to the registry's inbox. Send a vault key links to it.",),
    ('v0.7.34', '2026-10-10', 'git 9a2093cb',
     "THE READER CARD: RATE IT, AND THE RATING SETS THE PRICE. The line at the foot of every article becomes a card "
     "headed 'Your reading account', linked to how paying for reading works here, with the balance beside it. It shows "
     "what the page has cost, how much was read and the price to the end; the article is in the active persona's "
     "reading list from the first time it is opened (one tap removes it); and three five-step scales, each starting in "
     "the middle and drawn as an outline until touched: How useful was this? sets the price (1 free, 2 half, 3 the price, "
     "4 one and a half times, 5 double), More like this? moves the page's topics and tags in the persona, Level of detail "
     "is kept as the persona's preference and shown in the newsroom. 'Don't charge me' with a reason moves to the end as "
     "the exception. Ratings are in what a reader shares. SG Meter v1.3.0 (new path; earlier versions kept): rate(), "
     "the rating and autoKeep config, the sg:meter.rated event; docs updated; going live with the reading meter gains a "
     "dated note on the change. test_meter.mjs checks the reading list and the free and double prices.",),
    ('v0.7.33', '2026-10-10', 'git fc0c23e6',
     "LATEST, IN THE ORDER THINGS WERE PUBLISHED; A FASTER TAG STEP. Latest, the archive, the wire and the feed sort articles by date and time, but the time came from a hand-written time: line, and agents wrote round numbers: on 10 October an article committed at 14:21 said 17:00 and sat above four published after it (24 of 64 articles had a written time that disagreed with git). The time is now the article's first commit, read from git; time: is only the fallback for an article not yet committed, and one dated today with neither takes the build's clock. Older days are re-ordered to match. CI: the tag step pushed every historical tag on every release, about 330 round trips and two minutes the deploy waited on; it now asks the remote once which tags are missing and pushes only those, and when two commits claim one version the oldest wins (v0.7.28 had been given to a superseded commit).",),
    ('v0.7.32', '2026-10-10', 'git 1a69fcfa',
     "WHERE THE VAULT KEYS LIVE. A new article on vault key management at sgit-ai v0.20.0, after a reader found the gap: "
     "the architecture (S3, a Lambda API behind CloudFront, decryption in the client), the one secret and the keys derived "
     "from it, where vault keys are kept today (a password manager, and a registry vault run by an isolated session), how a "
     "new key reaches the registry sealed on a write-only append lane, why giving a key to an agent is the weakest step "
     "(environment variables shared per environment; network secrets for API keys only, on Pro and Max), append lanes as the "
     "transport behind most of it, small communication vaults, and the roadmap: password managers, secrets.sgit.ai's "
     "passkey-unlocked keyring, PKI and out-of-band decryption. Six figures. New docs page Vault key management; vault "
     "messaging now says which host serves which route names (send.sgraph.ai v0.32.4 still on /api/vault/inbox/*, "
     "dev.send.sgraph.ai v0.33.69 on /api/vault/append/*) and that the lane address derivation is still proposed at "
     "v0.20.0; credentials and send a vault key link to the new page. The re-anchoring article records the first observed "
     "re-anchor, a container restart that took the policy with it, and the fixed leak scan counter. Built on the full git "
     "history, which restores the article version diffs that v0.7.31 emptied (it was built from a shallow clone, so every "
     "diff read as 0 paragraphs changed)."),
    ('v0.7.31', '2026-10-10', 'git 536b8b7f',
     "SEND US YOUR READING, GET A FRONT PAGE DESIGNED FOR YOU. A new page, account/share.html (in the Newsroom menu as "
     "'A newsroom designed for you', and linked from your newsroom and your account), offers the deal plainly: read as you "
     "normally would, then send us what you read, and Dinis replies with what your front page could look like, built "
     "from your actual reading. The page shows word for word everything that would be sent: a readable summary (pages, "
     "spend, topics, personas, the newest 40 pages with depth read, cost and decline reason) and the same data as one "
     "JSON object (up to 300 rows), with no payment references. Copy puts it on the clipboard for an email or a message; "
     "Send takes a name, an email, an optional note and a consent tick, encrypts it in the browser with sgit's hybrid "
     "envelope v2 to the subscribe identity's published key (fingerprint recomputed first) and writes it to the subscribe "
     "vault's write-only lane, marked X-SGit-Form: reading-share so the list agent files it apart from subscriptions; "
     "the newsletter is a separate tick. The drain brief documents the new kind. SG Meter v1.2.0 (new path, earlier "
     "versions kept): the share view, shareData(), the share config, the sg:meter.shared event; docs updated. Pay to "
     "keep your persona gains 'Help us design it'. admin/build/test_meter.mjs covers the preview and the copy (23 checks "
     "pass); the encrypted send was verified separately by decrypting an intercepted POST with a throwaway key, so no "
     "test message was put in the real inbox.",),
    ('v0.7.30', '2026-10-10', 'git 362dfa6a',
     "THE INFOGRAPHIC BAKE-OFF. Every image-output model on OpenRouter on 10 October 2026, its auto-router and a code-drawn control, given eleven briefs from the Re-anchoring article over five rounds (an 18-word stat card to the whole article, a 14-number chart, an edit, a UI component, a brand slide, a three-slide deck with and without a style reference, and the bake-off's own conclusion). 101 images judged blind by Designer agents, OCR as a check, $10.44 recorded by OpenRouter, an Accountant and a Data Scientist on the numbers. Recommended: Nano Banana 2.1 (concept slides, charts, decks), Gemini 3 Pro Image (when speed matters), GPT-5.4 Image 2 (documents, edits, components, brand). A new vault, Infographic bake-off (po5i477i, read key published), holds every brief, image, score, reason and cost as an app, with the guidance; and the article, with six figures.",),
    ('v0.7.29', '2026-10-10', 'git ab006b20',
     "ARTICLES HAVE VERSIONS, AND FIVE READERS FOLDS AWAY. Every article now carries its own version beside the site release it was published in: v1.0.0 is the article as first published, a change to its text is the next minor version, a change only to its other details the next patch. The history is read from git at every build (renames followed, cached for shallow clones), so nobody keeps the numbers and publishing is still adding one file. Each article has a versions page (61 articles, 195 versions), and every version after the first has a page showing what changed, paragraph by paragraph, with a word-level diff. The diff renderer moved into admin/build/article_versions.py and article_diff.py now uses it (output unchanged). Version pages quote earlier text verbatim and are exempt from the em-dash and retired-word style rules, not from the leak checks. Read it another way becomes Five readers: one closed line under the abstract saying what is inside, the five readers closed within it, the article version the views were read from, and links to the other articles the readers have read and to the Article Views vault.",),
    ('v0.7.28', '2026-10-10', 'git 55aee85d',
     "PERSONAS, AND A NEWSROOM OF YOUR OWN. SG Meter v1.1.0 (new immutable path assets/components/sg-meter/v1/v1.1/v1.1.0/; "
     "v1.0.0 kept) gives every reader personas, kept in the same local state as the balance. A default persona is built "
     "from everything read, weighted by depth, and named from its top topics until the reader names it ('The Policy "
     "Architect, with a streak of the Cartographer'). Five starting personas built from what the site publishes "
     "(PERSONAS in the build, which refuses a preset naming an article that does not exist): Morgan the founder, Rowan "
     "the journalist, Sam the security lead, Kai the AI builder, Jordan the board member. As many personas as a reader "
     "wants; reading is credited to the active one; any article can be kept in a persona or put out of it, from the "
     "newsroom or from the foot of the article. Picks now score tags as well as topics. New pages: account/newsroom.html "
     "(your newsroom: personas as chips, picks for the active one with Keep and Not for this persona, what was kept and "
     "put out, and its graph) and account/personas.html (rename, switch, remove, add from a preset or blank). The graph "
     "view draws a persona as SVG: the persona in the middle, topics sized by weight with arcs in proportion to their "
     "pages, the pages behind each topic, and the citations between them. Picked for you moves off the front page (now "
     "one line linking to your newsroom) and off the account page, which now draws your graph and can restore an export "
     "in another browser. State version 3, older states upgraded in place. Docs at /meter/ updated (personas, four new "
     "views, config, events, schema, changelog). New article: pay to keep your persona (why readers should pay because "
     "it helps them, not out of guilt; personas as focus; the iPad problem; a persona that follows you as the thing worth "
     "paying for; H6 and H7). Going live and who will game the reading meter link to it; their pre-registered "
     "hypotheses are unchanged. Tested in Chromium: v1 state migration, keep from the page, presets, curation, reads "
     "credited to the active persona, rename, export and restore into a fresh browser, no overflow at 390px, no errors.",),
    ('v0.7.27', '2026-10-10', 'git 759a5fda',
     "ONE ARTICLE, FIVE READERS. The articles are deep and hard to consume, so they are now read again after they are written by five agents with defined roles: a Librarian that catalogues every fact, claim, number, question and source with its verbatim sentence, a Cartographer and Ontologist that builds concepts and verbs at article, topic and site altitude and draws the maps, a Historian that says what the article added and where it sits, an Explainer that says it in two minutes, and a Storyteller that tells it as a deck. Run on the five behaviour-policy articles of 7 to 10 October: 662 items, 32 problems flagged in published text, 45 concepts, 78 edges, nine maps, five decks of nine slides, and one new concept in the newest article. Those five articles now open with Read it another way (two minutes, the arc, the deck with a PDF, the map, the catalogue with its flags), in the page and its markdown twin. A new vault, Article Views (chtgtd9e, read key published), holds the role files, every output, the renderers, the checks and an app; and the article that tells the story.",),
    ('v0.7.26', '2026-10-10', 'git bf3701ab',
     "THE READING METER GOES LIVE, AND BECOMES A LIBRARY. A page is now charged for the share of it the reader scrolled "
     "through (price times deepest point, rounded to 5%, never less than a tenth), once per page per session, topped up "
     "as they read further. The balance may go below zero forever: no bar, no block, no ask, only a small balance in the "
     "top bar that turns a warmer colour below zero. At the foot of an article, 'Not worth it? Don't charge me' refunds "
     "the page with one of four reasons, kept in the reader's history ('not relevant' counts against that page's topics "
     "in the picks). Prices raised to 10p a new article and 5p an older one, read to the end. Topping up becomes one £5 "
     "Stripe Payment Link returning to account/topped-up.html, which adds the credit once per session id and, "
     "deliberately and documented, cannot verify the payment; until the link is set in METER, the simulated cart "
     "remains. The meter is rebuilt as SG Meter v1.0.0 (assets/components/sg-meter/v1/v1.0/v1.0.0/), an ES-module web "
     "component on the estate's SgComponent base, markup and styles in sibling files, page data set only as text, events "
     "sg:meter.charged/declined/toppedup/reset/changed, six views (balance, page, account, topup, topped-up, picks); "
     "assets/meter.js is removed and the reader's v1 balance and history carry over under the same storage key. New "
     "docs section /meter/ (install, views, rules, config, events, storage schema, theming, Stripe setup, changelog) "
     "and /meter/security.html (nine known gaps, all accepted, with what each costs the site and the reader). Two "
     "articles: going live with the reading meter (the plan, the Guardian's counter and tiers, the subscription "
     "commitment, Stripe's fees on £5, the honesty-box evidence, five hypotheses with thresholds and a review date "
     "eight weeks after the link goes live) and who will game the reading meter (eight kinds of reader sized from "
     "published figures, agents as a lift between them, nine ways to cheat or leak, and the risks that will actually "
     "happen). Tested in Chromium: depth charging, refund on decline, no recharge on reload, negative balance, top-up "
     "once per id, storage blocked, no console errors, no overflow at 390px; on a phone the balance takes the version's "
     "place in the top bar.",),
    ('v0.7.25', '2026-10-10', 'git 4037ed3d',
     "OPEN SOURCE IS NOT FREE. A new article on the economics of open source's long tail, from one Intel iMac that "
     "could not run Homebrew or Docker Desktop: the case checked against the vendors' own pages (only 2017 iMacs stop "
     "at Ventura; Homebrew's installer refuses Intel Macs; Ventura unpatched since August 2025), the long tail measured "
     "from Homebrew and PyPI analytics, where money flows for old versions and where it does not, what a 20p card fee "
     "does to a payment of pence, and five funding models simulated over 10,000 seeded draws, with break-even prices, a "
     "sensitivity tornado and the satellite support firms. Eight figures from the new Long Tail Ledger vault (deqiwj7z), "
     "published with its read key: 175 sourced facts, 36 labelled assumptions, the model in Python and the same model "
     "in the browser behind a calculator, gated to agree exactly."),
    ('v0.7.24', '2026-10-10', 'git 9fffe410',
     "A READING METER IN THE BROWSER. Every page now has a price and a meter that debits it from £5.00 of starting "
     "credit, all of it in the reader's localStorage: no account, no server, no card, nothing sent. Prices come from one "
     "METER table in the build, injected into every page with what the page is (article, issue, note, collection, page, "
     "free) and, for an article, its date and topics: a new article (seven days) 5p, older 3p, issue, note or collection "
     "2p, any other page 1p, homepage, subscribe and account free. assets/meter.js charges once per page per browser "
     "session, never blocks (out of credit, the read is recorded as unpaid and a bar offers a top-up), shows a badge "
     "with the balance, and keeps a history that personalises the site: a Picked for you band on the front page and on "
     "the account page, unread articles from the reader's most-read topics, computed in the browser from "
     "articles/graphs.json. New pages: account/ (balance, history, topics, receipts, pause, export, start again, price "
     "table) and account/top-up.html (packs, cart, review, confirm, receipt: every step but the payment). Rules carried "
     "over from pt.newsroom.sgit.ai's wallet. Tested end to end in a browser: debits by kind and age, the free revisit, "
     "the picks, the cart and receipt, the unpaid read. The article: a meter in the browser.",),
    ('v0.7.23', '2026-10-10', 'git ed8f0cd7',
     "ONE MAC, MANY AGENTS; NO NAMES; FULLER COLLAGES. A Mac of the agent's own gains a section on queueing many agents on one Mac: six scenarios against the licence clauses (purpose and who is using macOS decide it, not the queue), the case worth a lawyer's hour, and the narrow ask for Apple's written terms. Threat-sized security: the eight fictional startups are described by what they do instead of named, in the vault (v0.1.2, gate and tests passing, audited from a fresh read-key clone), its screenshots, the article and the ladder figure, which loses its ceiling column. Newsletter collages: three pictures become one large beside two stacked, two pictures hug their content, so screenshots are no longer letterboxed.",),
    ('v0.7.22', '2026-10-10', 'git 0335818e',
     "RE-ANCHORING. Instructions given only in conversation can be lost when a session is summarised; this site's session was summarised eighteen times in a month, keeping about 1.7% each time (an infographic from its own transcript). Re-anchoring keeps the rules in an ABP file and prints it back after every summary (SessionStart, matcher compact); a canary status report, computed from the transcript and checked by a Stop hook, ends every few answers, the brown M&M. Both are running in this session. Three ways a rule fails, one file with three jobs, a measured rule, recipes that age with the platform. Two infographics, a diagram with its Mermaid source, the report on a phone and as a card, graph JSON.",),
    ('v0.7.21', '2026-10-10', 'git 2592327f',
     "AGENT DESK, WHERE IT COMES FROM. The article A Mac of the agent's own, the Agent Desk vault page and the business plans list no longer say the plan was written after companies replied to the earlier research; it now simply follows that research.",),
    ('v0.7.20', '2026-10-09', 'git c5dd0c88',
     "A SECOND READER THE AGENT CANNOT SKIP. On 9 October two emails drafted by the inbox agent went out in the founder's voice although a written rule forbade it. The agent's own fix, read against the Claude Code documentation and the research: a hook on the draft tool, code checks first, then a fresh model call with only the rules, the sources and the draft, failing closed and logging every verdict. What the first run caught; six kinds of independence; why placement decides the barrier (a session mod or a plugin is a setting, only managed placement is final); three public ways to build it; maker-checker, two-person rule, AI control, LLM-judge bias, Dual LLM, CaMeL, Rule of Two. Three diagrams with Mermaid sources, an independence infographic, the hot-reload prompt, graph JSON.",),
    ('v0.7.19', '2026-10-09', 'git 8df16cd1',
     "HARI'S POST, LINKED. The AI governance stack article and the AI Governance Graph vault page now link Hari Kota's LinkedIn post, The Full AI Governance Stack; the vault moves to v0.1.1 with the post as the source of every node in Hari's words, and its derived facts are refreshed.",),
    ('v0.7.18', '2026-10-09', 'git 4255f61d',
     "THE AI GOVERNANCE STACK, AS A GRAPH. An answer, built, to Hari Kota's question under the post The Full AI Governance Stack: how would you structure the graph across the layers? A new vault, AI Governance Graph (1wp3xpf4, read key published), and its article. Hari's table kept exactly as posted, every cell a node (85 nodes in Hari's words, gated byte for byte); the ten edges the table already contains; each layer its own world with one shared grammar; 119 cross-layer edges of ours, each resting on a provision (EU AI Act Art 26(2), Art 4 as reworded by 2026/1744, Annex III 4(a), NIST AI RMF GOVERN 1.6, ISO/IEC 42001 7.2); 2026 changes as dated edges under Hari's labels; the do-this-today test and the gaps in layers 3, 5 and 10 as queries on a fictional shop; what '4 or 5' means, three ways; bring your own CSV. Two diagrams with Mermaid sources, ten app screenshots, vault page, graph JSON.",),
    ('v0.7.17', '2026-10-09', 'git 75eb72fc',
     "RFC 0001: PUBLIC-KEY CRYPTOGRAPHY FOR SGIT. The site's first Request for Comments, from the sgit CLI agent's design session: nothing is built. Today a read-key holder who can write to the store can author history clients accept; turning the read key into a public key does not fix it, two key pairs do. A writer signature on today's vaults first; native PKI vault mode (readers cannot write, hosts cannot forge, write-only depositors); sealed files, an age v1 inner envelope to named people, with the private key in a file, an ssh key, hardware or a remote service. A landscape of thirty key services and tools, checked from their own documentation: RSA-OAEP-256 is the format the remote services share, approval of each decryption is rare, and three best fits each for a small team, a company that wants every decryption approved, and open source only. Fourteen questions, comments by issue or email. Three diagrams with Mermaid sources, graph JSON. Headings can now carry an {#anchor}.",),
    ('v0.7.16', '2026-10-09', 'git 1677e2ef',
     "A MAC OF THE AGENT'S OWN. A new business plan vault, Agent Desk (5ej8boc8, read key published, the first created and audited with sgit-ai 0.20.0), and its article, from a voice memo, following our research on renting an agent a desktop. Agents already have their own mailbox, code-host account and Claude account; the next resource is a desktop, and it should be a Mac. Apple's macOS licence, quoted from versions 15, 26 and 27, rules out a pool of Macs rented by the minute, so the plan is the shapes it allows: your Mac run for you, a dedicated Mac with a per-minute meter, developer agents, software for your own Mac, and asking Apple for terms. A clean desktop per run, built from vaults by a short bootstrap whose key steps were tested, keys by PKI, erased at the end. Three Agent Behaviour Policies for the same customer service agent: unbounded excess 23, 11 and 3 rows. A calculator, a Wardley map, three diagrams with Mermaid sources. No provider named. Vault page, business plans list, graph JSON.",),
    ('v0.7.15', '2026-10-09', 'git a98a1c20',
     "THE NEWSLETTER AS A BEST-OF ITS PICTURES. Two desk directives: !figure takes one image from an article or a "
     "vault by its path from the repository root, and !collage composes two to six of them into one 1920x1080 picture "
     "with a label under each, rendered by make_banners.mjs into articles/banners/collages/; both fail the build if an "
     "image does not exist. Issue 2 now opens on the day in six pictures, one from each article, and carries the agency "
     "scale vault, the two complexity maps, the threat ladder, the three customer service designs and a hostile email, "
     "the memory pattern and the local evidence log. The LinkedIn box at the foot of an issue, which the owner used for "
     "the last post, now lists every image in the order it appears, with its caption, because LinkedIn keeps the text of "
     "a paste and drops its images. A single-day range now reads 'published on'.",),
    ('v0.7.14', '2026-10-09', 'git 01c4ef0f',
     "THE LINKEDIN SIDE, LINKED. The Deterministic GenAI newsletter's own page is now linked wherever the site names "
     "it (the newsletter page, every issue, the subscribe page), its URL supplied by the author rather than guessed. "
     "Articles take an optional linkedin: field for the URL of the same piece cross-posted as a LinkedIn article; it "
     "shows as 'also on LinkedIn' in the byline and is carried in newsroom/wire.json. First use: liquid content needs "
     "water, published on LinkedIn on 9 October.",),
    ('v0.7.13', '2026-10-09', 'git 7fc16399',
     "TEN HARD QUESTIONS FOR RISKMANDATE, ANSWERED. A new article from a two-hour interview in which Claude, acting as a journalist with an eye for detail, challenged Dinis on ten questions a co-founder brought back from a conference: bypass, keeping policies current, tailoring and price, insurance and liability, differentiation, the supply chain, who enforces, remediation, agent to agent, and outcomes. Written for readers who have not seen the questions: the model every answer rests on, the questions, the answers in brief, three things RiskMandate deliberately is not, an honest maturity table, three fixes the exercise found on riskmandate.ai (checked and still present), and the ten answers in full, linked to 46 pages across sgit.ai, abp.sgit.ai and riskmandate.ai. One figure with its Mermaid source, graph JSON.",),
    ('v0.7.12', '2026-10-09', 'git 9d7be0dd',
     "HOW I WORK WITH CLAUDE. A new article, a practical guide from a voice memo and a follow-up note, for people joining the team and anyone setting up their own agentic workflow. One session per major or recurring topic, not one long mixed thread; sessions named 'project | work', agent sessions with an @; an agent is a focused session with a role.md; memory is what the session reads, curated as websites, graphs and vaults wired together; access by default rather than public by default; vaults to share by key instead of broad permissions; review as the step that fixes the brief; four starter prompts; tips on previews, proof-of-concept sites, skills, policies before connectors, and a Cowork session per agent. One figure with its Mermaid source, graph JSON.",),
    ('v0.7.11', '2026-10-08', 'git d0f7e2dd',
     "THE DESK CATCHES UP: THIRD EDITION, TWO COLLECTIONS, NEWSLETTER ISSUE 2. Eleven articles had reached the site "
     "since the second edition, all live and complete. The Editor's third edition leads with hope or enforcement, one "
     "customer service agent built three ways with a vault that counts which rules are only hoped for; highlights are "
     "agency is not a yes, the waiting room knew first, every mistake added a rule, and the business-logic article from "
     "its pitch; the story vault and Reader Skills pitch is parked as a highlight and placed at the head of a featured "
     "collection instead. The Historian adds two collections, How agents decide and when they stop, and Local news kept "
     "as evidence, closes board card D6 by adding four articles to Behaviour policy in practice, and writes a thread "
     "note on why an agent's range means its stop has to be designed. A first draft of that note said none of its four "
     "articles cited the others; one links all three, and the note was corrected before release. Issue 2 of the "
     "newsletter covers 8 October, grouped by theme, and records the bridge correction; Issue 1 now links its LinkedIn "
     "post.",),
    ('v0.7.10', '2026-10-08', 'git 7d88785b',
     "THE REPORTER IS PAID, NOT IN PROFIT. A correction to the Mill Street Bridge vault (v0.3.1) and its pages, after Dinis spotted it: the economics table counted the reporter's £930 base salary as her income and again as her cost, so her 'profit' was only the top-up. Her salary is the Courier's cost, not hers. Her row now shows pay: base salary £930 plus a commission of 20% of every use, £541.48, £1,471.48 in all, with no cost and no profit; 'top-up' is now 'commission' throughout. Every figure is unchanged. Vault page, article and three screenshots updated.",),
    ('v0.7.9', '2026-10-08', 'git ee22b824',
     "THE WAITING ROOM KNEW FIRST. A new article and a new vault, from a live local story. On the afternoon of 8 October 2026 staff at two London hospitals of one trust said the IT systems were down, about five hours was announced, and a doctor called it national; nothing a local person could check before leaving home showed it. The article is about the gap, not the hospital: where local information comes from now, what parts of the UK already publish (Northern Ireland live emergency waits for every hospital, Wales live, England no national page), the thinning of local news and social feeds, and the narrow legal duty to warn. The vault, Local evidence log (92yb24y9, read key published), keeps the evidence by six levels, fourteen claims and what would resolve them, twenty six sources with how each was reached, the leads that do not fit, sixteen places a local could look, the enquiry to the trust (drafted, not sent), and scheduled source re-checks; 16-check gate, audited with the read key. No patient details anywhere. Vault page, four figures, graph JSON.",),
    ('v0.7.8', '2026-10-08', 'git be27891d',
     "CLOUD PATTERN FIX. In Encrypted memory for isolated agents, the cloud diagram drew the Lambda option outside the VPC. The agents have to reach whichever server is chosen, so both options now sit inside the VPC: an internal load balancer on a private address, and behind it either the container (EC2, Fargate or Kubernetes) or the same app on Lambda attached to the VPC, both with S3 storage through a VPC endpoint. Figure, Mermaid source, text and the self-hosting docs page updated.",),
    ('v0.7.7', '2026-10-08', 'git b39da50f',
     "ENCRYPTED MEMORY FOR ISOLATED AGENTS. A new article and a new docs page on sgit deployment patterns, from a voice memo. Agents are safer in isolated, often ephemeral places, which takes away the shared drive and the disk; the memory has to live where the owner controls it and a breach exposes nothing. Five patterns, each a Mermaid diagram with its source: a Mac mini with the vault container and a folder (Docker Compose file and agent entry point), one agent run with a scoped clone, Kubernetes (Deployment, Service, NetworkPolicy, a Job per task), a private cloud VPC with S3 and CloudFront, and two servers holding one vault. Tested on a local server with sgit-ai 0.20.0: scoped clone, push from a second agent, replication to a second server and a restore after a restart, and a search of the server's storage that found no plaintext and no file names. Also shown: a scoped clone is not an access boundary, the deployment docs' --endpoint should be --base-url, and pip installs need mcp<2. New docs page Self-hosting a vault server for agents, with links from the docs index, limitations and the deploy page. Six figures, graph JSON.",),
    ('v0.7.6', '2026-10-08', 'git 4da1cb49',
     "KNOWING WHEN TO STOP. A new article, from a voice memo, read after Agency is not a yes. The hardest call is often when to stop, for people and for agents. People: security champions who automated whole classes of bugs away and then debated whether GUIDs were random enough (a version 4 UUID has 122 random bits); delivery teams that hit every KPI and did not move the business; teams that find work as they grow. What stopped them was perspective, much of what seniority is: the attackers, the phase of the business on a Wardley map, the bottleneck, good enough, and shipping to see whether it mattered. Agents have the same problem by design, because their range is the feature. The answer is constraints that carry perspective: direction, a mandate, memory with the bigger picture, graphs that narrow the scope, a stop named in advance, one kind of work per step, a reviewer with agency, and shipping. One figure, graph JSON.",),
    ('v0.7.5', '2026-10-08', 'git 9dfbf5d0',
     "AGENCY IS NOT A YES. A new article and a new vault on human and agent agency, from two voice memos. A yes or no is the least interesting part of a decision; agency needs options, context in the decider's terms, depth (provenance, zoom), time or tokens, symmetric incentives, escalation and authority over the source. The vault, The agency scale (ddfw24hx, read key published), turns those into seven levels from rubber stamp to delegator where the weakest dimension caps the decision, scores fourteen cases by the same rule (eight at level 0, from the label-ID prompt to an agent asked 'is this OK?'; the author's draft review at 5; the one email an agent may send at 6), offers an assessment for any decision point, draws the review as a QA loop that fixes the source, and maps the EU AI Act's oversight articles to a floor at level 3. Below level 3, accountability belongs to whoever designed the decision point. 17-check gate, audited with the read key. Vault page, figures, graph JSON.",),
    ('v0.7.4', '2026-10-08', 'git 43f2f081',
     "HOPE OR ENFORCEMENT. A new article and a new vault showing the Agent Behaviour Policy at work. One customer service mandate for a fictional homeware shop, built three ways: one model connected to the mailbox and the database with a careful 38-rule policy (97% of it expectations, kept only by the model); the same model behind a harness of fourteen business tools (73%); and a team of nine narrow agents behind a deterministic identity gateway, with tools bound to the verified customer (23%, unbounded excess zero). Sixty-two fictional emails, sixteen hostile, go through all three; one run's reach falls from 38,000 records, any refund and any address to one customer, 100 GBP per order and no other address; tokens per email from about 35,000 to 4,400. Two hostile emails still work inside the narrow design's mandate, and the policy names the boundary to build next for each. The vault (wz9dw0m5, read key published) has the mandate, the designs and their policies, every email through every design, the analyst's report after the run, the client's promises and a year that recomputes live; deterministic simulation, a 20-check gate, audited with the read key. Vault page, figures, graph JSON.",),
    ('v0.7.3', '2026-10-08', 'git a2314406',
     "WHO ARE YOU PROTECTING AGAINST? A new article and a new vault. The article, from a voice memo, starts from an entrepreneur's design, a Mac mini that will never touch the internet, with nothing installed and the application written in the Perl that ships with macOS, and asks who that protects against: three questions (threat agent, attack vector, sophistication), a six-tier ladder from your own mistakes to states built on NIST SP 800-30, the NCSC's commodity, targeted and elevated threats and MITRE ATT&CK, the note that DSIT's AI Risk Management Toolkit asks who the new threat actors are without defining them, the Mac mini read tier by tier, local versus cloud, and selling better than the customer's baseline rather than beyond their needs. The vault, Threat-sized security (zwlqqvkm, read key published), holds the ladder, eight fictional startups each with assets, an attack tree with an ATT&CK technique on every branch and the line it should draw, a compare matrix, the Mac mini comparison and a five-question questionnaire; four themes, a 19-check gate, audited with the read key. Vault page, five figures, graph JSON. The bridge simulation's catalogue line now says three institutions, as its pages do.",),    ('v0.7.2', '2026-10-08', 'git cb675bdc',
     "THE VERSIONS PAGE LINKS TO WHAT CHANGED, AND PAGES REFRESH THEMSELVES. Each commit id on this page now links to the commit on GitHub (the newest row to the comparison with the release before), and under each note are the pages that release added or changed, read from git once per release and cached in admin/build/version_changes.json so a build without the history still has them. And a fix for pages that stayed stale on an iPad: GitHub Pages sends every page with a ten-minute cache the site cannot change, and Safari can show a tab from memory long after that. Every page now asks /version.txt, uncached, which release is live, on load, when the tab comes back and when it is restored from memory; if the page is from an older release it reloads once under a URL no cache has seen, then tidies the address. Only on sgit.ai, never in the vault host or a local preview.",),
    ('v0.7.1', '2026-10-08', 'git 647cb6f4',
     "EVERY MISTAKE ADDED A RULE. A new article on complexity, for the founders who have become engineers with agents and are doing the right thing: a friend's verification, run by agents in Cowork with ChatGPT reviewing, where the code holds and the process breaks (compaction, lost outputs, 100 KB prompts, scripts edited in place, rules against the harness, rules breeding rules). Two Mermaid Wardley maps show complexity as a position, a custom-built blob of process where commodities already exist, and the same process with each piece made small, shipped and moved right. Then the principles: map it, commoditise small chunks and let them compound, ship and stop, small sessions and your own context, memory as versioned files, slow down when complexity hits, security by asset and attack vector, rules for incidents and machines for enforcement, five environments, reverse-engineer the path, learn the engineering that exists; and direct answers on compaction, audit cards and what deserves a STOP. The friend is not named and the project not described. Four figures, graph JSON.",),
    ('v0.7.0', '2026-10-08', 'git e328e3be',
     "THE BRIDGE VAULT v0.3, AND A NEW MINOR. The bridge-simulation vault moves to v0.3.0, a design and quality pass "
     "worth a version of the site of its own. Design: drawn on a canvas first, then built; the navigation is five "
     "numbered groups with an icon per view, people as people, counts read from the data, Previous and Next; four "
     "themes, the same Night, Day, Paper and Ember as secrets.sgit.ai, every colour a token in one file. Quality: an "
     "architect agent reviewed the code against the vault-app guidance here, coding.sgit.ai and nfrs.sgit.ai (16 "
     "findings, a 28-step plan) and a developer agent applied the first four phases: rendering escapes by default, "
     "failures show instead of blanking, keyboard-safe Ask and API views, accessible charts, byte-for-byte downloads, "
     "and a 44-check gate. Model: the reporter is on the Courier's payroll with a 20% top-up from every use, the "
     "reporting is sized at 31 hours by the first week's payments, every party ends the closure in profit, and each "
     "later story records what the editor expected against what the town paid for. The bridge article and the vault "
     "page are updated to match, every screenshot re-taken under -v3 names, a four-theme collage added, and 'four "
     "institutions' corrected to three. The vault key was handed to the key registry on its append lane.",),
    ('v0.6.103', '2026-10-08', 'git 36e02f84',
     "OPEN THE VAULT. The bridge trail's last card had 'Open the app', which went to the vault page's live embed; it is "
     "now 'Open the vault' and opens the vault itself in the official UI with its published read key, in a new tab. A "
     "trail link can be marked external, which adds the arrow and the new tab.",),
    ('v0.6.102', '2026-10-08', 'git 4493e481',
     "THE BRIDGE VAULT v0.2, AND TRAILS. The bridge-simulation vault gains the economics and the data, nine new views "
     "and a second build script (tools/economics.py): costs and profit (fixed costs first, variable with use; the "
     "reporter is under water during the closure; break-even on day forty), two years on (£4,378, 96% from "
     "institutions and agents after the reopening), the next eleven stories (reuse saves 157 of 331 reporting hours; "
     "the first week's payments decide what is investigated; the long tail is 41% of year two), trust as a forecast "
     "record, the council's relief scheme and two accountant agents, a simulated model query over the claims live on "
     "any date, the data assets, and a Swagger-style explorer for an OpenAPI 3.1 description whose every endpoint is a "
     "GET of a file in the vault. The bridge article gains a section on it, a collage of the five front pages and one "
     "of twenty views; the vault page is rewritten around v0.2, every screenshot re-taken under a new name so no cache "
     "serves the old one. NEW: trails. `!trail <name>` in markdown, or <!-- trail:<name> --> in a page, renders a row "
     "of cards from admin/content/trails.json, paths resolved per page and the current page marked; the bridge trail "
     "(Markus Franz's article, the Reader Skills article, the bridge article, the vault) is on all three pages.",),
    ('v0.6.101', '2026-10-08', 'git 14f08970',
     "THE SUBSCRIBE VAULT IS THE LIST. Vault y9j3nc60 becomes the subscribers' single source of truth: "
     "list/subscribers.json holds the current state (status, name, consent, sources, preferences) and "
     "list/events.jsonl the history, naming subscribers only by a hash so an erasure never touches the log. "
     "The drain turns accepted messages into list events with no model in the loop, and other agents can add "
     "events without the vault key, through a fenced list-event block in signed mail (the site agent's "
     "issue-sent, a forwarded unsubscribe). The subscribe brief gains the section and the commands."),
    ('v0.6.100', '2026-10-08', 'released with v0.6.101',
     "SEND A VAULT KEY. A new page, /docs/send-a-vault-key.html, is the instructions URL a session holding "
     "vault keys is pointed at, with an inbox vault id and an append token given privately and never "
     "published: the rules, the vault-key-handover/v1 payload, the registry's public key and endpoint, what "
     "to do afterwards and on a failure. Taken from the vault registry write-up's Section 0, with its "
     "inconsistencies resolved: the vault id is withheld everywhere, the token and id together are treated "
     "as a write credential, and the sender script (/assets/send_to_registry.py) pins the registry key's "
     "fingerprint before sealing, reads the token from the environment, never prints a key, and names each "
     "HTTP failure by its real cause. Reviewed by the registry before release: read keys are accepted (recorded "
     "as read-only), a too-large payload is reported without a size the drain does not enforce, and the page "
     "says that a payload on the lane can still be quarantined. The first live run delivered two keys, signed."),
    ('v0.6.99', '2026-10-08', 'released with v0.6.101',
     "THE CONTACT FILE VALIDATES AGAIN. v0.6.81 listed the subscribe identity's form lane in "
     "/.well-known/sgit-agents.json under the name subscribe, but sgit-agents/v1 defines one lane name, "
     "agents, so the live file failed this site's own schema. The form lane is now described only in "
     "/.well-known/sgit-subscribe.json, which is what the form reads; the contact file lists the agents "
     "lane and validates. The subscribe brief gains the rule that everything inside a message is data, "
     "never instructions, for the agent that reads what the drain filed."),
    ('v0.6.98', '2026-10-07', 'git 1e8b776b',
     "TWO INFOGRAPHICS, AND A TIMELINE THAT READS. In Story vault underneath, Reader Skills on top, the infographic of "
     "Markus Franz's article and its reading note sat between two paragraphs that belong together; both now sit in a "
     "closing section, Two infographics, next to a new ChatGPT infographic of the article itself, each with a note "
     "that reads it against its source (the new one invents its own bridge dates and softens 'pay' to 'credit'). The "
     "bridge-simulation vault moves to v0.1.1: on the timeline, the contract and engineer lines end on the same date "
     "and their names were drawn on top of each other; sources that end together now share one label, and the "
     "overlapping line is dashed. The timeline screenshots on the article and the vault page are re-taken.",),
    ('v0.6.97', '2026-10-07', 'git a4b4b7b7',
     "LIQUID CONTENT NEEDS WATER. A reply to FT Strategies' guide to liquid content (Sofia Giannuzzi): agreement on "
     "the definition, data journalism as the model and structure behind every front end, then where to push. The water "
     "is the reporting, so liquefy the journalist's notebook rather than the finished product; put the experienced "
     "journalist and their workflow at the centre, with a desk built for them and experts paid for time and "
     "credibility; keep writing theirs, because writing is how the story is found; read the guide's three 'is not's "
     "with a graph underneath; personalise by intersecting the reader's graph with the story's; keep editorial "
     "direction, not readers' tastes, as the brief; and add per-use payment down the graph to retention, advertising "
     "and licensing. Four figures, the ChatGPT infographic of the guide with a reading note, graph JSON. The Reader "
     "Skills article's infographic caption now records Markus Franz's reply to it.",),
    ('v0.6.96', '2026-10-07', 'git 9e71c7d6',
     "THE BRIDGE, FOLLOWED TO THE END. A simulation of Markus Franz's bridge example, played out on a story vault: a "
     "fictional town, Wendmouth, whose bridge closes; the council says one week, the contract three, an engineer five "
     "to seven, and it opens on day forty-six. The vault (bridge-simulation, published with its read key) holds the "
     "evidence, claims with their contradictions, the paper, the newsroom, a timeline (newsworthy on five days, needed "
     "on fifty-seven), three readers with their own graphs, skills, decisions and WhatsApp messages, the institutional "
     "and agent buyers, where the money goes, the view without it and the short/medium/long, local/regional/national "
     "impact; every figure is computed by its build script from written assumptions. A new article walks through it, "
     "and the Reader Skills article gains the ChatGPT infographic from the voice note, with a reading note on what it "
     "gets right and what it overstates.",),
    ('v0.6.95', '2026-10-07', 'git 3ee1826f',
     "A SUBSCRIBE PAGE, AND ISSUES AT DATED URLS. sgit.ai/subscribe/ is the one page with the newsletter form: what an "
     "issue holds, what comes next, where else it is published, what happens to the address, and the latest issue. "
     "Everywhere else the form becomes one line pointing there: the newsletter index (which now opens on its issues), "
     "the front page, the foot of every article and of every issue. A 1920x1080 subscribe cover is rendered for the "
     "end of a LinkedIn article. Issues move to articles/newsletter/YYYY/MM/DD/NNN-<slug>.html (slug from the issue's "
     "frontmatter); Issue 1 is now .../2026/10/07/001-agents-doing-real-work.html, moved before its LinkedIn post went "
     "out, and the one link to the old address was updated. In an issue a quote no longer carries a From line: the "
     "owner's edit of the LinkedIn version read better without, since every quote follows a paragraph that links its "
     "article. The build now requires exactly that, and fails a quote whose paragraph does not (tested).",),
    ('v0.6.94', '2026-10-07', 'git 24e14a65',
     "STORY VAULT UNDERNEATH, READER SKILLS ON TOP. An article on the intersection of Markus Franz's Liquid "
     "Utility and Reader Skills (The Article Is Only the Beginning, 7 October 2026) with the story vault, from "
     "his public reply to a comment and a voice note: each of his six skills mapped to an operation on the "
     "story graph and each of his safeguards to a property it already has; his bridge closure drawn as a "
     "graph in which one supersede edge is the update, the alert and the correction; and the local angle his "
     "piece adds, local contributors and local outlets paid back down the chain of claims that regional, "
     "national and international stories rest on, with an illustrative split and the loop it starts. His "
     "words quoted verbatim and credited. Three figures, a graph, a cover, a pitch; listed in newsletter issue 1.",),
    ('v0.6.93', '2026-10-07', 'git d86d0b70',
     "ARTICLES IN THE ORDER THEY WENT OUT. Four articles went out today and Latest listed them by filename, so "
     "the newest sat third. Articles now carry an optional time: HH:MM in UTC, and every list of articles "
     "(Latest, the archive, the wire, the front's freshness check, the feed) sorts by date and then time. "
     "Without the field the build uses the time the file was first committed, when that commit is on the "
     "article's date. All 37 articles that have a same-day commit now carry it explicitly, backfilled from git, "
     "so the order does not depend on a clone's history; the two that were backdated keep none and sort after "
     "the timed ones of their day. The feed's pubDate now carries the time instead of midnight. Documented in "
     "CONTENT.md and on How to publish.",),
    ('v0.6.92', '2026-10-07', 'git b757ef64',
     "NEWSLETTER ISSUE 1, OPENING ON THE TOPICS. The issue opened by explaining what the newsletter is; a new "
     "reader cares about what the week was about. The intro, the summary and the subtitle now lead with the "
     "question the week's articles answer, what it takes to let agents do real work for a business, and the "
     "four sides they answer it from. The behaviour-policy-as-business-logic article gets a paragraph and a "
     "checked quote in the agents section. Not yet posted to LinkedIn, so the issue changes before it goes out.",),
    ('v0.6.91', '2026-10-07', 'git d6609661',
     "THE BEHAVIOUR POLICY IS THE BUSINESS LOGIC. An article from a voice note: below the first mechanical rules "
     "(own account, secrets held by the platform, tools switched off, sending needs a person), an agent's "
     "behaviour policy is the company's own business logic, function, process, relationship and purpose, which "
     "many organisations never wrote down because their software enforced it by omission, and which agents "
     "bypass by working through APIs. One fictional firm's email agent walked through six layers with an owner "
     "and a barrier per rule; the rules counted into backed by a control, governed by an accepted risk, and hope; "
     "five rules shown moving when a kind of product that already exists enforces them; the link to AI BCF RG.2 "
     "and AC.3; and why the upper layers read like skills. Three figures, a graph, and a pitch to the Editor.",),
    ('v0.6.90', '2026-10-07', 'git feb73516',
     "THE NEWSLETTER, AFTER ITS FIRST PASTE INTO LINKEDIN. Issue 1 was posted and three things came back. A quote's "
     "source sat in a cite inside the blockquote and LinkedIn dropped it, so the source is now a paragraph after the "
     "quote, everywhere the desk quotes. The Copy for LinkedIn button is gone: selecting the page and copying works "
     "as well, links included. The full list of the week's articles was one long dated list; it is now grouped by "
     "theme with a line of introduction each, through a new !list directive, and a new !covers directive makes the "
     "grouping a promise the build checks: every article in the range must appear in some list, or the build fails "
     "and names what is missing (tested by dropping one).",),
    ('v0.6.89', '2026-10-07', 'git 9b643765',
     "THE SGIT NEWSROOM AND ITS NEWSLETTER. The articles section is named the SGit Newsroom in the menu and on the "
     "front; the backstage pages become How it runs, and every URL is unchanged. A newsletter content type: one file "
     "per issue in admin/content/newsroom/newsletter/, NNN-YYYY-MM-DD.md, rendered with the desk directives so its "
     "quotes are checked against the articles at build time, cross-posted as a LinkedIn article in Deterministic GenAI "
     "(the URL goes in the issue file once posted; the newsletter's own URL is left blank until supplied rather than "
     "guessed). desk.py says when an issue is due: a week after the last or five articles since it. Issue 1 is out. "
     "A cover for every article and issue: admin/build/make_banners.mjs renders 1920x1080 JPEGs through Playwright "
     "from a manifest the build writes (title, teaser, three key ideas from the graph's claims, the article's own "
     "hero, or for an issue the cards of what it covers), re-rendering only what changed; release.sh runs it between "
     "two builds. An issue page has a Copy for LinkedIn button that makes every link absolute. The subscribe form "
     "now subscribes to the newsletter. The quote check now allows a capitalised first letter, the convention for a "
     "quote that starts mid-sentence; it caught one such quote in Issue 1.",),
    ('v0.6.88', '2026-10-07', 'git a46446bf',
     "THE NEWSROOM'S FIRST EDITOR RUN. A release that touches only the Editor's files and the release lines, "
     "checked with policy_check.py --role editor. The second edition of the articles front: the open AI governance "
     "framework leads, raised from the highlight its pitch asked for because it carries the most checkable evidence "
     "of anything new (a vault with its read key, a browser database, a join to the AI Act); where is the why? joins "
     "the highlights and the Mandate Stack moves from lead to highlight. The first pitch on the desk, from "
     "agent@riskmandate.ai, is answered in its status line; its collection request goes to the Historian as board "
     "card D6, because collections are the Historian's. The log records a correction: the first entry's time was a "
     "placeholder, now the commit's 12:40 UTC.",),
    ('v0.6.87', '2026-10-07', 'git 76fafd89',
     "AN AGENT DESKTOP BY THE MINUTE. An article on how hard it is in October 2026 to rent a desktop that is "
     "safe to hand to an agent and billed for the minutes it works: nine properties a locked-down agent "
     "desktop needs (isolation, an egress allowlist, secrets kept outside, its own identity, a live view, a "
     "record, a clean reset, billing that follows the work, the right operating system); nine products set "
     "against them from their own documentation (E2B, Daytona, Modal, Browserbase, Windows 365 for Agents, "
     "Amazon WorkSpaces for AI agents, AWS EC2 Mac, Scaleway Apple silicon, Cua), none with all nine; two "
     "hours a day priced on each; Apple's 24-hour lease minimum and its developer-services purpose clause; "
     "why Claude's native and governed computer use do not meet; why the desktop, not the model, has to be "
     "the barrier; a proposal from what exists; and the startup credit programmes that would pay for testing "
     "it, verified on the day, with how to apply. Three figures.",),
    ('v0.6.86', '2026-10-07', 'git 9e42bb6a',
     "AI BCF AS A GRAPH. A two-part article on Jan van Dijke's AI Baseline Control Framework v1.0, twenty "
     "AI governance controls for deployers under CC BY-SA 4.0: what is good about it (deployer focus, three "
     "types with trigger conditions, a why and a how per control, mappings in the data, current to the 2026 "
     "amendment, a privacy-respecting site, a CSV export), what it brings (the Access controls, which the "
     "NIST AI RMF, ISO/IEC 42001 and the AI Act do not cover), and why an open licence matters for a control "
     "framework; then what the licence made possible the same day: a new vault, AI BCF as a graph "
     "(lop5iqzw, read key published), converting the CSV into a semantic graph of 127 nodes and 239 edges "
     "with an ontology, a SKOS taxonomy, JSON-LD and Turtle, eighty hyperlinked documents and a SQLite "
     "database that runs in the browser, joined by id to the Regulation Graph vault, with a fractal graph "
     "view, a crosswalk and SQL and triple consoles, and six findings the CSV does not state. Vault page, "
     "four figures, six app screenshots, and a pitch to the Editor. Also: a small size for !shot figures, "
     "used for the add-repository screenshot in Where is the why? so it sits in proportion with the text.",),
    ('v0.6.85', '2026-10-07', 'git 66138bbb',
     "THE NEWSROOM. The articles get an editorial layer, run in public at /newsroom/. Publishing is still "
     "adding one file: an article is live, at the top of Latest, in a new articles/feed.xml and in a new "
     "newsroom/wire.json the moment it exists, with no approval step. What changes is placement. The lead, "
     "the highlights, the homepage band and the featured collections now come from one file, "
     "admin/content/newsroom/front.json, written by one role, the Editor; every other agent asks with a pitch, "
     "one new file. The rule is the agent team's own, 'create anywhere, edit your own', adopted after 'only one "
     "agent may draft' became a bottleneck in two days. Six desk roles (Editor, Journalist, Historian, Designer, "
     "Developer, Contributor) are files whose write lists are the behaviour policy: the policies page is "
     "generated from them and admin/build/policy_check.py reads the same files. A placement naming a missing "
     "article is skipped and reported under desk health, never a build failure. The articles index is rebuilt "
     "as a front page (dateline, masthead, lead with the Editor's reason, a Latest rail, highlights, desk notes, "
     "collections, then every article); new collections and desk-note pages, whose !quote directive is checked "
     "word for word against the article at build time; and admin/build/desk.py prints the Editor's checklist. "
     "The homepage band follows the front. The top menu now opens with Articles; Updates and the version log "
     "move under Team. First edition: the Mandate Stack leads; two Historian notes, a weekly note and three "
     "collections.",),
    ('v0.6.84', '2026-10-07', 'git b92f41f8',
     "WHERE IS THE WHY? An article on a permission prompt that asked, mid-task, to add a repository to an agent "
     "session, with three fields and two buttons and no reason. It reads the prompt through the Agent Behaviour "
     "Policy, as a grant change whose barrier is a human judgement and whose risk lies in the combination with "
     "what the session already holds (read of a public repository adds little; write to it from a session "
     "holding confidential data adds a publishing channel); sets it against the record on decisions taken "
     "without the facts, from Montgomery and the red hand rule to GDPR consent, token oversight under WP251 and "
     "SCHUFA, and the moral crumple zone; lists the prompts that asked without a why and the fixes that worked, "
     "purpose strings, number matching, refusal as easy as acceptance; puts the vendor's own approval figures "
     "beside them; and proposes a why card that becomes a risk acceptance when the risk rises. The screenshot "
     "is included; sources are labelled and secondary ones marked. Seven figures.",),
    ('v0.6.83', '2026-10-07', 'git e0a2e1bd',
     "SGIT-AI 0.18.0 ON THE SITE. The CLI's release notes as an update post, carried here because the site had no "
     "change log for the CLI itself: scoped clones (--path), shallow clones (--depth), a full clone twice as fast "
     "through one parallel sweep of the store, a pull that keeps or refuses rather than overwrites uncommitted "
     "work, real ahead and behind counts, every pushed commit's files uploaded, and three hardening checks. Three "
     "new guide pages: Partial clones, Agents sharing one vault, and a five-minute update notice an agent team can "
     "be pointed at. Working with AI agents now lists the scoped and shallow clones first and sends a team to the "
     "sharing guide; Installation names the current release; Limitations gains the two partial-clone edges. The "
     "CLI team's measurements are kept with the conditions they stated, and this site re-ran the new flags, the "
     "scope rules, the status counts and the pull guard against a published vault and a throwaway vault before "
     "publishing; what was re-run is noted on the pages.",),
    ('v0.6.82', '2026-10-07', 'git 97a5bd1f',
     "THE SUBSCRIBE LIST GETS ITS OWN IDENTITY. subscribe@sgit.ai is a second identity in this site's contact "
     "file, with its own RSA-OAEP 4096 and ECDSA P-256 keys whose private halves live, passphrase-encrypted, in "
     "the subscribe vault itself (y9j3nc60): the passphrase and the enum key are derived from the vault's write "
     "key, so the vault key is the one secret and whoever holds it runs the list. The form now encrypts to this "
     "identity instead of to RiskMandate's key; a second lane, agents, takes signed agent mail and is verified "
     "against the sender's own contact file. The vault carries its own drain-and-send tool, tested from a fresh "
     "clone with only the vault key: a signed self-test accepted, an unsigned and a forged message quarantined. "
     "The brief is rewritten around it, and the agents page lists the new identity. Built on a sibling branch "
     "by the session that made the keys, reviewed here with the four fingerprints recomputed from the PEMs, and "
     "merged.",),
    ('v0.6.81', '2026-10-07', 'git bd3c5079',
     "THE MANDATE STACK, ROUND THREE, AND A SOURCE BLOCK FOR THE BUILD. A new article directive, !source "
     "label | image, placed before a code fence, renders the fence as a collapsed details block with a copy "
     "button and a link to the rendered image, so a diagram's source travels with its render without taking "
     "the reader's screen; the markdown twin gets the code block and the image link, because the twin is the "
     "copy an agent reads. The two Wardley map sources in the Mandate Stack article use it. The article gains "
     "a section on the vault as an app platform rather than storage: the loop in which a friction becomes a "
     "tool as HTML in the same session, is deployed into the vault, used and corrected, with the tools "
     "compounding down to an interface for one person, and the apps already deployed into vaults on this site "
     "as the evidence; a figure for it; and a custom-interfaces component on the inside map. The stack figure "
     "gives the human layer its own colour and its own line. The schedule is stated as four runs a day plus "
     "whatever a live session starts. The phrase about nothing planned is gone; everything described is in use.",),
    ('v0.6.80', '2026-10-06', 'git 692b7984',
     "THE MANDATE STACK, REVISED. The article now leads with the system that runs and its eight layers, and the "
     "published record of agent projects that stall moves to a closing section for readers who ask why a running "
     "system is worth writing down. The stack figure is recoloured as one gradient from compute to the human, with "
     "a column that says what each layer gives the team instead of naming pages that cannot be clicked. Two figures "
     "added: the left-to-right flow from the outside world through the layers to the person who sends, and two "
     "Wardley maps rendered with Mermaid's wardley-beta from sources printed in the article, one from the outside "
     "(a person who writes to the team) and one from the inside (the person running the business) with the "
     "movement the team is making: shared memory and the sgit CLI pushed toward commodity on encrypted storage, "
     "the behaviour policies and the graphs pulled from genesis toward product. Placements are stated as claims.",),
    ('v0.6.79', '2026-10-06', 'git dfc8f597',
     "THE MANDATE STACK. An article that sets one multi-agent system in production beside the published record "
     "of agent projects that stall: Gartner's cancellation prediction, S&P Global's abandonment survey, McKinsey, "
     "Deloitte, KPMG and Forrester between June 2025 and July 2026, each dated and labelled, with the reversals "
     "and the fair reading that agents do ship where the workflow stops at the draft. Built from a briefing the "
     "RiskMandate CRM agent wrote on 6 October 2026, it describes the setup in eight layers, rented compute and "
     "channels, encrypted vaults as shared memory with mail as files and append lanes, a vault per domain, "
     "semantic graphs over people, contexts, teams and policies, a conductor with security at both ends, written "
     "behaviour policies, and one human who sends; then the feedback loop in which the draft is the release "
     "candidate and the recipient closes the loop; then each reason the record gives for failure mapped to the "
     "mechanism that answers it. Every layer is hyperlinked to the article or document on this site where it was "
     "worked out. The name is a working one; nothing planned is included. Six figures.",),
    ('v0.6.78', '2026-10-06', 'git 269b6e39',
     "WHY MY AGENTS DO NOT RUN ON MY LAPTOP. An article on why no model runs on the author's laptop and the "
     "agents run on four cloud surfaces instead. An operating system has two hard walls, the kernel and the "
     "user account, and an agent on a laptop runs inside the one marked you, with no boundary between it and "
     "the SSH keys, cloud credentials, password-manager session and browser cookies the account can read; the "
     "desktop agents' prompts and sandboxes are settings enforced by the process they constrain. Fifteen months "
     "of reported incidents, July 2025 to September 2026, show what a mistake or an injected instruction reaches. "
     "The setup: Claude chat with nothing connected, Cowork with no repositories, Claude Code on the web with one "
     "repository and an allowlist, ChatGPT with no assets, and a vault the host cannot read as the shared drive "
     "between them, keys handed per session. The trade-offs accepted, three wishes (identity, secrets and a key "
     "pair per agent), and the caveat that a dedicated machine with separate accounts is a different model, to be "
     "reported on after it has been run. Links the earlier posts on the topic. Six figures.",),
    ('v0.6.77', '2026-10-06', 'git 5a9ea907',
     "THE DECK I COULD NOT DOWNLOAD. An article and a business-plan vault written after one presentation on "
     "SlideShare was offered as a 30-day trial and £10.99 a month while the deck's author receives nothing, "
     "under an uploader agreement quoted in the article that grants a royalty-free, sublicensable licence to "
     "monetise and train models on the work. The article gives the platform's history to the September 2021 "
     "paywall, why fifteen years of embeds and links have kept it in place, what an author can ask for today "
     "under the right of access and the EU copyright transparency duty, and the design of the service the "
     "author would have chosen: every author's decks in a vault the host cannot read, access by keys, seven "
     "roles no single company holds, pay once with 85% to the author. The Deck Vault (13djtu3j) carries the "
     "plan's ten documents, the right-of-access letter, a register and a working mock built as web components "
     "and bundled for the vault host; published with its read key, verified by a read-only clone and an "
     "all-zeros negative control. Six figures and eight app screenshots.",),
    ('v0.6.76', '2026-10-06', 'git 83369cae',
     "TWO ARTICLES ON PERSONAL AGENTS. The first describes the RiskMandate agent team as it runs, from the "
     "agents' own field notes: twelve agents on dedicated accounts, encrypted vaults as the only memory, "
     "messages as files, a CRM of one folder per person, a conductor with security first and last, and a "
     "mailbox nobody sends from; then the security properties as properties, three classes of information "
     "the vaults hold or refuse, and every piece mapped to the idea on this site that argued for it. The "
     "second reads the 2026 personal agents, Muse, dots, Spark, Autopilot, Alexa+, Siri AI, Cowork and "
     "OpenClaw, through the behaviour policy and through data sovereignty, with each fact dated and marked as "
     "vendor statement or report, and then designs a privacy-first personal agent on vaults, a policy as the "
     "permission authority, browser compute first and an attested enclave with per-task key release second. "
     "Thirteen figures.",),
    ('v0.6.75', '2026-10-06', 'git 4733eaf8',
     "EVERY REPOSITORY, ONE PAGE. The author asked whether a central guidance existed for how the estate's "
     "sites, tools and code are built, and the check found four partial ones and no page above them. "
     "/docs/guidance/repositories.html is that page: what every repository carries regardless of kind, the "
     "brief in full written by a person or an agent, the reality and corrections files, one version going up "
     "on every push, the gate, the release discipline, the review folder, the code rules by reference to "
     "coding.sgit.ai, credentials, the NFRs, people and agents, writing, and two checklists. The review brief "
     "is revised from the author's reading: intent is written by a person or an agent and accepted by a "
     "person, and the navigator is no longer one file but web components in the coding.sgit.ai shape, one "
     "visualiser per file shape, with a first layout proposal and a wireframe, and a step that lifts the "
     "components into their own project.",),
    ('v0.6.74', '2026-10-06', 'git 3c9348eb',
     "THE REVIEW FOLDER, AS A BRIEF. A build brief, written for secrets.sgit.ai first and for every agent that "
     "writes code after that, turns the two code review articles into a thing a repository carries: a review "
     "folder of layered graphs, intent written top down from the brief and the code derived bottom up from the "
     "syntax tree, joined, every commit read upwards to the stories it can reach, and one self-contained page "
     "that walks from a story to a source line and back. New projects start at full coverage from the first "
     "commit; existing projects start from the changes. The tool is held to the same standard as the code, with "
     "a second review set that reviews the tool itself. Linked from both code review articles where they said "
     "the review did not exist yet.",),
    ('v0.6.73', '2026-10-05', 'git dc11503b',
     "SAY WHAT WAS FOUND, NOT WHAT NOBODY HAS. A review of absolutes in the four most recent articles, at the "
     "author's request: 'nobody has counted', 'impossible for a buyer', 'never scaled', 'every customer' and "
     "the like are replaced with what the evidence supports (few, rarely, I know of no public count, every "
     "customer on hosted runners), in the text, the figures and the graph files of the investigation, due "
     "diligence and code review company articles. The validator now lists remaining absolutes in article "
     "prose as an advisory warning on every build.",),
    ('v0.6.72', '2026-10-05', 'git 534f0b22',
     "THE EVIDENCE VAULT TAKES THE ROUTER FIX. The How Much Evidence vault app now holds its route in a "
     "variable and marks its links native, so its views work inside the vault host where the hash router "
     "was dead, and declares the host's minimal chrome; pushed to the vault with its write key and verified "
     "from a fresh read-only clone. The how-much article's section on form, workflow and tools records the "
     "two things that arrived while it was being revised: the subscribe form and this fix. The vault page "
     "carries a note.",),
    ('v0.6.71', '2026-10-05', 'git b67511e0',
     "MINIMAL CHROME BY DEFAULT, AND THE HASH-ROUTER TRAP. Every vault app this estate publishes now "
     "declares \"hud\": {\"mode\": \"minimal\"} in app.json unless it has a reason not to: the guidance "
     "front door, the vault-apps doc (whose example said full), the vault-app embed demo and the published "
     "create-vault-apps skill all say so. Found while checking why the How Much Evidence vault's links did "
     "nothing: its views route on location.hash, and inside the host the app is an about:srcdoc frame where "
     "setting the hash does not fire hashchange, so every view link was dead. The fix (route held in a "
     "variable, a window-capture click handler, data-sg-native on the links, the hash used only outside the "
     "host) was proven in the real host on a test copy of the vault; the guidance now carries the rule. "
     "The vault itself needs its write key to take the fix."),
    ('v0.6.70', '2026-10-05', 'released with v0.6.71',
     "SUBSCRIBE TO THE ARTICLES, THROUGH A VAULT. The articles index and the foot of every article carry a "
     "subscribe form. The reader's address is encrypted in their browser (sgit's hybrid envelope, RSA-OAEP "
     "4096 plus AES-256-GCM) to the public key of agent@riskmandate.ai and dropped into the write-only "
     "`subscribe` append lane of a new vault, the same pattern as the contact form on riskmandate.ai. If "
     "anything fails, or there is no JavaScript, the same request is offered as a plain email to the same "
     "agent. The vault id, lane token and key are public in /.well-known/sgit-subscribe.json; the only "
     "secret is the vault key, held outside this repository. A new brief, /docs/briefs/"
     "subscribe-lane-agent-brief.html, tells the agent how to drain the lane, with a drain script that "
     "was run end to end (browser to lane to decrypted message) before release. Found on the way: "
     "`configure` returns 404 on a vault that has never been pushed, which looks exactly like the "
     "documented wrong-key 404. Components: assets/subscribe.js, subscribe_block() in the generator."),
    ('v0.6.69', '2026-10-05', 'git ae21f758',
     "THE INVESTIGATION GITHUB OWES ITS CUSTOMERS. A new article written during the GitHub Actions incident "
     "of 5 October 2026 that held the previous release in its queue: why a fault reaching every customer of "
     "the platform the world deploys through is a near miss for all of them and a statement about how it is "
     "built; what aviation does instead, from ICAO Annex 13 and the NTSB to NASA's confidential near-miss "
     "reporting; the first, second and third story, told for the UK air traffic control failure, the 2024 "
     "security update outage and tonight; the what-if ladder; why the market does not fix it; why the old "
     "objection to independent investigation, that the evidence is too confidential and expensive to share, "
     "has expired now that signed vaults with one-way read keys exist; what the arriving regulation does and "
     "does not do; and four things to ask for. Five figures, one of them the incident's own timeline beside "
     "this site's queued release.",),
    ('v0.6.68', '2026-10-05', 'git f7e55192',
     "THE EVIDENCE BEHIND ONE ARTICLE, AS A VAULT. A new vault, How Much Evidence, holds every number in the "
     "how-much article as a file: the session's 266 rows, 124 releases and 27 days; per-article ledgers with the "
     "message ids behind each; 28 corrections with their latency; the 29-article dependency map with every "
     "article's graph; the article's own fractal from strategy to data; the record by era; 75 article versions "
     "with a diff between any two; and a hash and screenshot of each cited source; with a nine-view app, built by "
     "a data scientist agent and a developer agent from a brief. The article is revised to the vault's numbers and "
     "says where its first version was wrong (eleven compactions, not twenty-two; 148 authored messages, not 281), "
     "gains a section on the vault with four of its views and one on why a published trail is a better universe "
     "for a model to learn from, and its ledger now has eight rows. Vault page with the read key.",),
    ('v0.6.67', '2026-10-05', 'git 8616d828',
     "SEND AN AGENT, NOT A SPREADSHEET. A new article turns the code review company to face the buyer: due "
     "diligence never scaled because it was a questionnaire answered by the vendor and disconnected from the "
     "code; a buyer can now send a prompt or a small agent under a behaviour policy into the vendor's "
     "environment and get back a derived graph of what reaches production unreviewed, what is documented and "
     "threat modelled, what the bugs touched and which agents act under what policy, with the vendor redacting "
     "but not rewriting. Risk-based tiers, the behaviour policy as the due diligence document, the regulation "
     "arriving before the visibility (SBOMs, the Cyber Resilience Act, the Product Liability Directive), the "
     "evidence that review is being skipped, and the startup's two advantages. Five figures.",),
    ('v0.6.66', '2026-10-05', 'git d234ea8c',
     "THE MAP REDRAWN, THE WORKFLOW NAMED, THE CHANGES SHOWN. The article map on the graphs page is redrawn with "
     "labels set along each node's radius, so none collide and none floats off the ring as one did; edges are "
     "weighted by how often one article mentions another and coloured by direction in time, with a line of "
     "numbers under the map. The how-much article gains a section on the form, the workflow and the tools, two "
     "figures of the map and its own graph, the second infographic by another model with its two wrong numbers "
     "named, corrected citation counts (seventeen of twenty-seven, not nineteen), and a link to a new kind of "
     "page: the changes to an article between two versions, paragraph by paragraph, from a diff tool built today.",),
    ('v0.6.65', '2026-10-05', 'git 6ba12b0a',
     "HOW MUCH OF THIS DID I WRITE? A new article measures the session that wrote the last twenty articles: "
     "every word the author sent, every word that came back, the rounds each piece went through, the "
     "corrections by kind, and the record the memos stood on. Three figures and a published dataset with the "
     "method and its limits. Seven articles accounted for by hand.",),
    ('v0.6.64', '2026-10-05', 'git 0ad26f6e',
     "THE SOURCES, LINKED. Every piece of earlier writing the code review company article weaves in now links "
     "to its public copy: the SG/Send team briefs and articles on GitHub as markdown, the diniscruz.ai posts, "
     "the Villager team brief in the sgit CLI repository, Wardley's own posts, Example Mapping, the SecDevOps "
     "book, and the sibling sites. The article also gains its one-page condensation by another model as a "
     "closing section, checked against the text, with two paraphrases in quotation marks named as such.",),
    ('v0.6.63', '2026-10-05', 'git 9e4d1cb4',
     "WHY IT IS ONLY NOW POSSIBLE. The code review company article gains the two ideas its author found missing "
     "on first read: that one technology can now read every layer from strategy to bytecode, which is what makes "
     "a graph at every altitude buildable and close to reality and why earlier attempts failed; and that this "
     "moves code review from an art of opinion and power to a science of facts, with budgets as the objective "
     "good enough and the condition that the models build and maintain the graphs and then leave the line. "
     "Agent Behaviour Policies are woven in as the control on the reviewing agents. The title and summary are "
     "shortened. Graph updated.",),
    ('v0.6.62', '2026-10-05', 'git 4d6a7b92',
     "NO MORE RUNGS. The word is gone from every article, its graph JSON, the code review vault page and the "
     "eleven figures of the two code review articles, at the author's request and in the spirit of the em-dash "
     "rule: a graph has layers and altitudes, a ladder has steps, a price list has tiers. The validator now "
     "fails any article that uses it. Elsewhere on the site the word remains until those pages are rewritten.",),
    ('v0.6.61', '2026-10-05', 'git a3108bd2',
     "THE IDENTITY ARTICLE AS ONE PAGE, BY ANOTHER MODEL. The identity article gains a closing section with "
     "the infographic ChatGPT made from its published text, checked claim by claim against the article before "
     "it went up, with the one paraphrase in its last panel named as a paraphrase.",),
    ('v0.6.60', '2026-10-05', 'git 4fab1507',
     "IF SOMEBODY BUILT A COMPANY ON CODE REVIEW. A follow-up to the code review article, written to answer a "
     "reader's questions and a voice memo: what is fractal in a fractal semantic graph (the grammar) and what "
     "is not (the layers, which each company defines); the projected graph built from stories before the code "
     "exists and the derived graph built from the code, with the review as the join; the layers the first "
     "example had but did not name; stories as rules and examples rather than prose; a refactor as relative "
     "to the layer held still, which the first article stated too narrowly; deploys and infrastructure as "
     "rungs of the same graph; who reads the code at each stage of evolution, after Wardley; reshaping the "
     "change rather than reviewing it as it arrived; budgets per altitude and the five whys as the loop that "
     "makes the next review cheaper; and open source as the only business model that fits. Six figures.",),
    ('v0.6.59', '2026-10-05', 'git e885e517',
     "THE IDENTITY WE WANTED TO GIVE THE AGENTS. A new article captures the week a plan to give every agent "
     "and user a Google Workspace identity from one tenant met Google's terms: the resale and function-account "
     "clauses in their current wording, with one correction to the design pack's own citation (the 'substitute "
     "service' clause is in the legacy free-edition agreement, not the current terms); the five options; the "
     "rule that no secret can live inside an identity provider; the passkey-unlocked keyring that fell out of "
     "it; what the industry's agent identities do and do not do; and the gap the research confirmed, that "
     "login, zero-knowledge storage and agent identity are still three separate jobs. Five figures. With it, "
     "the five design documents and the starter prompt are published as a brief, the identity and secrets "
     "design pack, as written and to be corrected.",),
    ('v0.6.58', '2026-10-05', 'git 413c644f',
     "THE CORRECTED ONE-PAGE CONDENSATION. The reply-tail article's infographic by another model is now the "
     "third version: the placeholder third-party domain is gone, the credit spells the site correctly, and "
     "the fictional reply is dated on the right weekday. One stray hyphen remains in a label and stays, as "
     "it came.",),
    ('v0.6.57', '2026-10-04', 'git 025e22b7',
     "THE EXPERIMENT PARAGRAPH, IN THE AUTHOR'S WORDS. The reply-tail article's experiment paragraph now "
     "reads as Dinis rewrote it for the LinkedIn repost: the experiment runs on his own correspondence "
     "via the agent@riskmandate.ai agentic team, and the note box links the LinkedIn repost. A revised "
     "version of the one-page condensation arrived without the third-party placeholder domain but with "
     "the site's name misspelled in its credit line, so the first version stays until a clean one comes.",),
    ('v0.6.56', '2026-10-04', 'git b5a36310',
     "THE ARTICLE, CONDENSED BY ANOTHER MODEL. The reply-tail article gains a closing section with the "
     "one-page infographic ChatGPT produced from the published text a few hours after release, kept in "
     "that model's own style and credited, because a condensation made for a reader by a different model "
     "is the thing the article proposes, done to the article. The caption says the thread is fictional "
     "and the addresses are placeholders.",),
    ('v0.6.55', '2026-10-04', 'git 12ba9608',
     "THE WALL UNDER THE REPLY. A new article proposes ending an email reply with the state of the thread "
     "written for this reader (decided, open, next, who is on copy, sources) instead of the quoted wall, "
     "drafted by the drafts role from the typed blocks the inbox role already keeps and reviewed by a "
     "person. It credits the precedents (RFC 1855's summary-instead-of-quote in 1995, bottom line up "
     "front, Minto, TL;DR, decision records) and the reader-side AI summaries in Gmail, Outlook, Apple "
     "Mail, Superhuman and Shortwave, and draws the line between a sender's reviewed statement and a "
     "reader's private view. It ends as an experiment on the author's own correspondence: four variants, "
     "what the record can measure, and what would show the idea is wrong. Five mock-up figures with a "
     "fictional thread.",),
    ('v0.6.54', '2026-10-03', 'git 25fd0ca1',
     "MEMORY IS NOT A SPECTATOR SPORT. A new article on agentic memory as context management: many "
     "context-specific memories rather than one store, fractal so an agent loads only the altitude its "
     "question needs, published and open so they can be fetched and cited, shared through vaults, with "
     "provenance on every item. It reads the industry fairly (vendor memory features, MemGPT to Mem0 and "
     "Zep, Letta's filesystem benchmark, Manus, Anthropic's context engineering guidance and memory tool) "
     "and uses the session that wrote it as the evidence. Four figures. Two stale facts the site's own "
     "memory carried are now measured at build time instead of remembered: llms.txt said the full-text "
     "file was about 155 KB when it was 2.5 MB, and graphs.json and updates.json said they were "
     "generated on 15 August. Two more are named in the article and left standing: the 617-node figure "
     "on the fractal graphs index, and the updates feed that stops on 21 September.",),
    ('v0.6.53', '2026-10-03', 'git 77644f73',
     "ARTICLE AND VAULT POINT AT EACH OTHER. The code review article gains a nine-view contact sheet of the "
     "vault app and an 'open the vault' box in its worked-example section, and every screenshot it draws from "
     "a vault page's own image folder now links to that vault page: a build rule for articles, in the same "
     "tab, with the caption saying so. The vault page gains a box under its lead naming the article as the "
     "argument and a closing 'Read the article' section. shots.js learned a per-figure link target.",),
    ('v0.6.52', '2026-10-03', 'git e9d1e85f',
     "CODE REVIEW AS A FRACTAL SEMANTIC GRAPH. A new article argues that source code is already layers within "
     "layers, each a graph with its own vocabulary, and that a code review should diff every layer: a refactor "
     "moves the bottom and leaves the top still, a fix is a story that holds again, and the blast radius is the "
     "climb from changed methods to the stories that can reach them. It revisits method streams from the O2 "
     "Platform, cites C4, Gherkin, code property graphs and the 2024-2025 evidence on generated code, and ends "
     "with a company for somebody to build. With it, a new vault, Code Review Graphs: the sgit CLI at two commits "
     "read from its syntax tree with nothing run, 377 classes, 1,111 methods, 2,592 calls, 72 commands, eleven "
     "stories, two method streams, ten house-rule checks and one commit read upwards, with a browsing app and the "
     "scripts that made it. Read key published; the vault page proves the key clones and cannot push. Five new "
     "figures in the shared style; the article graph has fifteen nodes.",),
    ('v0.6.51', '2026-10-03', 'git ca720692',
     "THE ROSTER, REDRAWN WIDE. The briefs role's team card was a tall phone-sized image and sat badly in "
     "the inbox article. It is redrawn at the width and in the type of the other eight figures, with every "
     "role, the email path, the two rules and the run order kept.",),
    ('v0.6.50', '2026-10-03', 'git 1576cdd8',
     "THE TEAM, AS THE BRIEFS ROLE DREW IT. A second contribution from the agents joins the inbox article: "
     "an infographic of the twelve-agent roster, grouped by what each touches, with the path an email takes "
     "to leave and the order of a run. The article now names the four roles the walkthrough leaves out "
     "(@linkedin, @zapier, @newsroom, @abp) and corrects the schedule to four runs on weekdays and two at "
     "weekends.",),
    ('v0.6.49', '2026-10-03', 'git a9362127',
     "THE AGENTIC INBOX, REVISED BY THE AGENTS. The dev agent of the team the article describes sent a "
     "review pack a day after publication: eight figures (mock-ups and infographics, fictional data, "
     "leak-checked), the roles as they are now named (@inbox, @drafts, @crm, @briefs, @dev, then "
     "@conductor, @security, @webSummit, @gdrive), the schedule that now exists, the security hold "
     "behind the one exception, the clone cost of a busy vault, the key-rotation lesson, three amended "
     "checklist lines and its own role paragraph. All of it is in the article now, with the pack credited "
     "in the byline. The figures were re-rendered without their burned-in caption bars so the page's own "
     "captions carry them, and four of them taller, because the fixed canvases had clipped the last rows "
     "of the two tables, the message and the hold. The article graph grew to sixteen nodes.",),
    ('v0.6.48', '2026-10-02', 'git 36a4d1b7',
     "£3,000 A SEAT. The event plan's value line is the sum of the two sides, about £3,000 a seat, with "
     "the two £1,500 halves shown wherever there is room so anyone can check it against the two price "
     "lists. The decision about which line to use is closed.",),
    ('v0.6.47', '2026-10-02', 'git 0a01c548',
     "THE EVENT PLAN, DRAFT 3. The DECID:R value is confirmed: a facilitated 90-minute session lists at "
     "£1,200, typically up to twenty people and fifty done, and the evening runs two, which the organisers "
     "round to £1,500, the same as the RiskMandate side. The page now says about £1,500 from each partner, "
     "and proposes one room-wide incident on the shared display rather than six platform runs, with the "
     "tables differing in the policies they write.",),
    ('v0.6.46', '2026-10-02', 'git dcff5281',
     "TWO LABELS TRIMMED on the event plan's pictures, where the last words touched the box edge.",),
    ('v0.6.45', '2026-10-02', 'git e79c2927',
     "THE EVENT PLAN, DRAFT 2. After the two organisers' first call: an evening, not a day; two sessions "
     "at the same table, the incident without a policy and then with one; a single room sponsor at ten "
     "thousand pounds, in cash or as the venue with food and drink; the first thirty seats free at about "
     "fifteen hundred pounds of value each, seats after that paid, the surplus split evenly. The page now "
     "opens with four pictures that carry the model, starting from the participant: what you bring, what "
     "you leave with, the evening, who pays for what, and the three partners. A messaging section writes "
     "the pitch, the invitation, the offer and the sponsor ask in the participant's terms first.",),
    ('v0.6.44', '2026-10-02', 'git e57663ef',
     "THE DRAFT'S FIGURES, TO FIT. On the live page the three inline figures of the event plan ran their "
     "text past the boxes, because the web fonts set wider than the estimates the SVG was drawn with. "
     "Wider canvases, wider boxes, shorter lines.",),
    ('v0.6.43', '2026-10-02', 'git 93391b60',
     "A DRAFTS FOLDER. A place for a page that is shared by link before it is public: drafts/ is "
     "not walked by the validator, not listed in the sitemap, disallowed in robots.txt and marked "
     "noindex, and nothing links to it, which is the point. Each draft is a self-contained HTML file "
     "with a slug nobody would guess. The first one is the execution plan for a one-day London "
     "workshop event with RiskMandate, DECID:R and the Open Security Summit, written for the two "
     "organisers to argue with; nothing in it is confidential and nothing in it is agreed.",),
    ('v0.6.42', '2026-10-02', 'git 23d0aad3',
     "REPLICATING THE AGENTIC INBOX. A new article, written from a voice memo the day two calls asked "
     "how to copy the setup: a practical walkthrough in phases, from one Claude session with a policy to "
     "a team of roles talking in files through a vault, with the accounts first (a Workspace mailbox and "
     "a Claude Team seat of the agent's own, connectors enabled by the owner and connected by the agent's "
     "account, the person's calendar shared read-only) and one rule that never changes: the agent drafts, "
     "a person sends. On its own page, deliberately: the agents who run the roles will add their own "
     "account of each role in the next revision. One figure, the four phases. Its graph is written, so it "
     "takes its place in the cards, the threads and the map like the others.",),
    ('v0.6.41', '2026-10-02', 'git 31bd8b25',
     "THE MAP LABELS, ACTUALLY FIXED. v0.6.40 said the labels on the article map no longer overprinted; "
     "they still did at the very top and bottom, because with twenty-one articles the two neighbours of "
     "the top node sit at seventeen degrees, just inside the band that put their labels above them too. "
     "The band is narrower now, so only the one node nearest each pole takes the label above or below, "
     "and its neighbours take side labels nudged toward the centre.",),
    ('v0.6.40', '2026-10-02', 'git 0b9e1389',
     "THE GRAPHS, PUBLISHED AS DATA. The founder asked whether the new article cards and graphs were "
     "generated from data rather than written into HTML. They were, but the data itself was not "
     "published: the build consumed the JSON and emitted only pages. Now it also emits the JSON. "
     "articles/graphs.json carries every article with its teaser, topics, tags, card, the links to "
     "and from other articles already resolved, and its full graph, plus the topic list, node kinds "
     "and edge relations, so a page or an agent can draw the whole map from one fetch; "
     "articles/graphs/<slug>.json is each article's graph on its own. The graphs page says so and "
     "links both. Also: the labels on the article map no longer overprint each other at the top and "
     "bottom of the circle, and the map is wider so long titles on the right are not cut.",),
    ('v0.6.39', '2026-10-02', 'git 115a49a1',
     "THE ARTICLES, MADE FINDABLE. Twenty-one articles had become a wall of summaries, and the "
     "founder asked for a layout a reader can actually use. Three things. EVERY ARTICLE NOW HAS A "
     "SEMANTIC GRAPH beside it (admin/content/articles/graphs/<slug>.json): a one-sentence teaser, "
     "one or two topics from a fixed list of six, the core idea, the concepts and claims as nodes "
     "with typed edges, the links the article makes, and a quote or two. THE INDEX is rebuilt from "
     "them: the newest article featured with its picture and core idea, a filter by topic and a "
     "search box, a grid of picture cards with one-sentence teasers, and the threads between "
     "articles read from the links they make. Every article ends with a THREADS block: what it "
     "builds on, what continues it, its topics, and a link to its graph. A GRAPHS PAGE draws the map "
     "of the articles and each article's own graph as SVG, deterministic layouts so the same file "
     "always draws the same picture. The homepage band moves up to second place, directly under "
     "the four vaults, with picture cards and teasers instead of summaries. Card thumbnails are "
     "cut from the hero cards by make_og_cards.mjs. The updates page gains a contents list. Still "
     "to come from the same brief: date-based article folders holding the text, the graph and the "
     "materials, with the old links kept, and a newsroom-style front page.",),
    ('v0.6.38', '2026-10-02', 'git d2a2832e',
     "PRICE IT, THEN GIVE IT AWAY. A new article, the follow-up to the question is whether they miss "
     "it, written from a voice memo as a moment-in-time record and as a brief for the agents who will run "
     "it: define the product, price it, deliver it at a cost that grows a step at a time, offer it free to "
     "the people who already know you, and find out, brutally, whether you have a product. The cost on the "
     "other side is never zero, even at a price of zero, so the exercise is to measure it and cut it. "
     "The published facts it rests on: RiskMandate's four tiers at ten, fifty, five hundred and fifteen "
     "hundred pounds, and the invite-only early access programme live since 30 September. One figure: the "
     "ladder, the two costs, the fork. Also: the footprint and blast radius article and its brief now carry "
     "a status line saying the founders are still reviewing the proposal and will decide how to make it "
     "happen with the RiskMandate team, and the ultimate insider article points to it.",),
    ('v0.6.37', '2026-10-02', 'git 6a3c3095',
     "THE RISKMANDATE SANDBOX, AS A BRIEF. A project brief, written here first because the parts live "
     "here, for a place where a behaviour policy is tested rather than read: a real model runs against "
     "three stacked twins, the inbox store, the Gmail OAuth connector with its scopes enforced, and the "
     "MCP connector with its permission prompts, with the policy on and off, and the two footprints sit "
     "side by side. The Librarian of the __Send project answered six questions against v0.33.69: the vault "
     "chat with tools and a memory file system shipped in June, the bridge shipped with OpenRouter only and "
     "its own one-dollar session limits marked not security, the one-shot articles never published, the "
     "twins entirely on paper, and the credits model specified three times with the page to sell from "
     "always the missing piece. Four ways a model gets called from a browser with no server, the "
     "OpenRouter clauses that separate reselling access from shipping a product, the gateways that already "
     "resell, a map of which providers can cap spending and which only alert, and a build plan with the "
     "first demo at step three. One illustrative figure. Status: draft for the founders.",),
    ('v0.6.36', '2026-10-02', 'git 14a7e67e',
     "FOOTPRINT AND BLAST RADIUS. A new article proposes two additions to RiskMandate's Agent Behaviour "
     "Policy, whose four words, reach, mandate, gap and barriers, are all written before an agent runs. "
     "Footprint is what the agent actually did, read afterwards from logs, vault history or a connector "
     "twin, with nothing inline and no production access; against the mandate it yields near misses "
     "(footprint in the gap) and dormant rows (asked for, never seen), and read alone it is the mandate as "
     "practised. Blast radius is the measure on any row: what it would cost the business if used in full, "
     "today, with a reversibility flag, defined over the reach because whoever takes the agent over "
     "inherits the reach. Four illustrative figures, led by the gap as a map, each row shaded by what it "
     "would cost and hatched where there is no way back. The cloud identity tools that already compare "
     "permissions granted with permissions used are cited, and what they lack, a mandate, is said plainly. "
     "A companion brief carries the schema changes and four questions to the RiskMandate team, including "
     "which words are current, since their abp.html still reads grant and delta.",),
    ('v0.6.35', '2026-10-02', 'git 3ee0be54',
     "FIGURES TO SCALE, ABSTRACT TO SIZE. A desktop screenshot of the Custom UIs article showed its "
     "abstract running to two thousand characters and the six tall review figures sitting at the full "
     "column width, each taller than a screen and with internal type larger than the article's own. The "
     "figure directive gains an optional fourth field, narrow (62% of the measure) or medium (78%), "
     "centred, full width again on phones, with the lightbox still opening the full image; the six tall "
     "figures are narrow and the policy explainer is medium. The abstract is now eight sentences.",),
    ('v0.6.34', '2026-10-02', 'git 75111d24',
     "SAID PLAINLY. The closing section of the Custom UIs article drops the checkable-claim framing "
     "altogether: a claim is checkable by nature and the article is made of them, so prefixing one with a "
     "caveat was redundant. The section is now titled The future of email, the summary bullet and the "
     "front-matter line state the idea without preamble, and the body says what it would take for it to "
     "fail and leaves it there.",),
    ('v0.6.33', '2026-10-02', 'git 644cd5b5',
     "A CLAIM ANYONE CAN CHECK. The closing section of the Custom UIs article loses the phrase stated so "
     "it can be wrong, which read as negative and odd, in the title, the summary bullet, the front-matter "
     "line and two sentences of the body. The idea is the same, that the claim about the future of email "
     "is made in a form that can be checked against what happens, and it now says so plainly.",),
    ('v0.6.32', '2026-10-02', 'git 1fcf1f39',
     "THE FUTURE OF EMAIL, STATED SO IT CAN BE WRONG. The Custom UIs article gains its closing argument. "
     "Earlier announcements of the future of email, Wave, AMP for Email, Slack, Hey, lost to the inbox "
     "because they needed the recipient to install something. Two things are true now that were not then: "
     "messages are increasingly read by an agent on the recipient's behalf, and those agents reshape badly "
     "because the message carries no structure, the same problem the token bill article found for "
     "websites. So the claim, in a form that can be falsified: the future of email is sender-served "
     "structure, read by the recipient's agent, with the inbox as one view of the graph. Email-FS is the "
     "structure, Agent Contact is how agents exchange it, the vault is where the graph lives, and a plain "
     "email still works on day one. A bullet in the summary, a line in the front-matter summary, and the "
     "token bill article joined the threads list.",),
    ('v0.6.31', '2026-10-02', 'git 780ddcde',
     "WHAT EMAIL BECOMES. The Custom UIs article gains its last section, from the second review pack and "
     "the founder's memo: the transport stays, SMTP or an append lane, same message, same file; above it "
     "the message is a node in a graph and each reader gets the shape their moment needs, one word, a "
     "card, an email, a voice prompt, a board or the whole graph, as simple as the moment allows and as "
     "complex as the situation requires. The shape adapts to urgency, focus, language and culture, the "
     "relationship, and to whether the reader is an agent. Fractal semantic graphs stop being a theory "
     "because each reader stops at the altitude they need, and the article reconciles its own earlier "
     "line: email is still the medium a sender reaches for, but the graph is now the medium that carries "
     "the meaning and email is one of its views. The loop closes it: moment, shape, decision, record, "
     "better shape, with interfaces maturing at different speeds; better models make the loop faster and "
     "the structure is what lets them. Two figures with placeholder names; a bullet and a closing line.",),
    ('v0.6.30', '2026-10-02', 'git 3716f8ff',
     "THE ARTICLE, REVIEWED BY THE TEAM IT DESCRIBES. Custom UIs are not the exception, revised on a "
     "review from the RiskMandate agent team's CRM agent. Four additions: it starts with the user, one "
     "interface per moment and the test that every question asked in chat that a page could have answered "
     "is the next interface; eight days, not one afternoon, the stream in which ten agents and one human "
     "went from an empty vault to a working outreach practice, with the counts from the commit history "
     "(seven vaults, sixteen interface releases, about 230 commits, over 550 agent messages, six written "
     "policies, four keys rotated in a day after an agent's own leak check); who builds them, the team "
     "map and the one-direction flow of work; and two interfaces as mock-ups with placeholder names plus "
     "the policy explainer that goes out in first emails. Tone: every absolute claim of the form nobody "
     "has done this is gone, replaced by what we found and what we did; the inbox that joins the pieces is "
     "being built, not absent. Five figures from the review pack, no contact data in any of them.",),
    ('v0.6.29', '2026-10-01', 'git fa4b4889',
     "CUSTOM UIS ARE NOT THE EXCEPTION. An article from a voice memo that weaves the site's threads "
     "into one argument about following up: every message has a graph, and the graph has altitudes "
     "(the block, the message, the conversation, the company, the contact), which is where fractal "
     "semantic graphs were always going. Email is a medium, so a message is designed for the "
     "recipient's moment, as a projection of the conversation for one reader, the way the story vault "
     "treats an article; the thread is history you can fold away, and the one you already sent can be "
     "re-presented. A custom interface per message, thread, topic or question is not an exception but "
     "the left edge of the Wardley strip: genesis, custom, product, commodity, with the cards, the "
     "board, the voice debrief, the vault-app proofs of concept, deploy-docs, Kit Bag and the webmaster "
     "plan as the strip moving. The worked example is a page an agent built in an afternoon that lists "
     "the PDFs still owed and gives a place to drop them through an append lane, so the to-do and the "
     "place to act are one page. It compounds, because each interface teaches the agent how its user "
     "works and because automation outruns any inbox, so the interface becomes the prioritisation; and "
     "it is an agent surface, so it gets a policy. One figure, OG card built.",),
    ('v0.6.28', '2026-09-30', 'git f44752ff',
     "THE ULTIMATE INSIDER. An article that is the first pass at a conference talk, written from two "
     "voice memos so the founder can show the people they are talking to about speaking a concrete "
     "direction. Three things arriving at once: agents as the insider threat that never scaled before, "
     "because insiders were humans or static code and an agent is a reasoning engine in a loop with "
     "skills; infrastructure designed for none of it, with no journals, backups by the day, unmanaged "
     "identities, permissions that are the union of everything, and a habit of failing on its own; and "
     "risk management on spreadsheets, at the speed of quarters, when the decisions have to be made in "
     "seconds and in advance. Five 2025 incidents carry act one (Replit, Gemini CLI, Amazon Q, EchoLeak, "
     "the AI-orchestrated espionage campaign), the AWS and Cloudflare outages and ToolShell carry act "
     "two, the 48 per cent spreadsheet figure and the Gartner and MIT numbers carry act three; aviation's "
     "confidential reporting system against cyber's slipped 72-hour rule explains why we do not see "
     "more. The turn is the way out through the ABP, layered containment, graphs, the licence to "
     "operate and insurance, ending on the irony that constraint is what earns an agent autonomy. "
     "Every number linked; the practice observations marked as the author's. One figure, OG card built.",),
    ('v0.6.27', '2026-09-30', 'git 665a7295',
     "KIT BAG'S FIGURES, WHERE THE BUILD CAN SEE THEM. The ten figures for the Kit Bag page were "
     "committed under admin/content instead of beside the built page, so v0.6.26 shipped with the "
     "page and without its images. Moved. The first attempt to fix it reused the v0.6.26 subject and "
     "the release check refused to tag it, as it should, so this is its own release.",),
    ('v0.6.26', '2026-09-30', 'git 0824c472',
     "KIT BAG: THE SIXTH BUSINESS PLAN, AND THE POLICY IS THE CONTROL. A recovery-shopping companion "
     "for football and padel players on the shop the founder uses, as a Chrome extension you build "
     "yourself, or your agent does, and load from a folder. No store, on purpose, which removed three "
     "rows from the brief's register and left the interesting ones. Two ideas ride on the small tool "
     "and the plan is about them: open-source apps that an agent customises per person, with the vault "
     "as the distribution channel; and an Agent Behaviour Policy written before the code, in "
     "RiskMandate's four questions, that types every barrier honestly. Thirteen rows: four boundaries "
     "Chrome enforces (product and basket paths only, no other site, no background, no model endpoint "
     "until granted), three settings our code enforces, five expectations nothing enforces, one none. "
     "The extension works: facts from the shop's own ld+json (eleven real product pages checked), one "
     "click presses the shop's own button, an AES-256-GCM record under the person's passphrase with "
     "export to an sgit-ready tree, an optional model ask that shows its body first, and a Policy tab. "
     "A Playwright fixture test proves the customer's name in the page header never reaches the record. "
     "Two stacks of label facts, a sample record, a register as an acceptance record, the checks a "
     "customising agent runs, and a chapter for whoever commercialises it. Vault r53ldcxt, 33 files, "
     "read key classified, clone identical, negative control empty. Plans table and startups index "
     "gained a row.",),
    ('v0.6.25', '2026-09-29', 'git c9a0b6ab',
     "TWO MAILBOXES. The owner's decision: every email address on this site now points at one of the two "
     "mailboxes that are actively used and monitored, agent@riskmandate.ai for RiskMandate matters and "
     "agent@diniscruz.ai for everything else. The four summit pages that asked readers to write to a "
     "personal address now point at agent@diniscruz.ai. The Authentitas byline, on every PDF page and on "
     "the page, is now agent@riskmandate.ai, with the sgit.ai site session named as where the text was "
     "produced, so a reply goes to a mailbox someone reads. The /agents/ page says plainly that "
     "agent@sgit.ai is a lane identity for signed agent mail, not a mailbox, and gives the two email "
     "addresses for people and for agents without a lane. The contact file's operator entry carries the "
     "hub mailbox too.",),
    ('v0.6.24', '2026-09-29', 'git a2f68f79',
     "WHO WROTE THIS, AND WHAT IT IS A COMMENT ON. The Authentitas response PDF and page, revised on the "
     "owner's notes. Every page of the PDF now carries a byline naming the agent that wrote it, "
     "agent@sgit.ai, and the model, the way Article 50 asks; the byline is not typed by hand but "
     "generated from admin/build/agent_byline.json, which the agent's session keeps current from its own "
     "session record, and the same file feeds the {AGENT_BYLINE} token on pages. The cover now says "
     "plainly that the whole document is a comment on The Accountability Market and shows the paper's "
     "first page; the page shows it too. Every integration with Authentitas is marked as proposed, not "
     "built, on the cover, on the ladder, in the six-requirements table, on the operator page and on the "
     "proposal page, with a caveat that there has been no conversation and no code. The word for the "
     "steps of a ladder that the owner did not want is gone from the PDF and the page; it remains in "
     "twenty other pages of this site, listed for a decision. Also this release: the comms vault's drain "
     "tool verifies signatures the way sgit makes them (raw r||s over the ciphertext bytes), and the "
     "third self-test was accepted end to end against the live contact file.",),
    ('v0.6.23', '2026-09-29', 'git 66356409',
     "THE SITE AGENT EXISTS. sgit.ai now has one identity, agent@sgit.ai, and an open inbox. Its comms "
     "vault (id mb0mhpq7) holds the agent's RSA-4096 encryption and ECDSA P-256 signing keys, "
     "passphrase-encrypted with a passphrase derived from the vault's write key, exactly as section 5 "
     "of the Agent Contact spec says; the vault is never published, has no read key on this site, and "
     "is the one thing that defines who can speak as sgit.ai. A public agents lane is configured on "
     "the vault and its token, the public bundle and both fingerprints are in the contact file and on "
     "the /agents/ page. The lane was tested end to end before publishing: an agent-message/v1 .eml "
     "encrypted to the agent and signed by it, written through the public lane, listed with the "
     "derived enum key, fetched, decrypted, signature verified, plaintext identical, marked processed "
     "and purged; a wrong enum key and a wrong token both 404. Two corrections found on the way: "
     "purge needs the SG/Send access token as well as the write key (the API page said write key "
     "only), and the key store is kept in sgit pki's own folder layout so a new session restores it "
     "with one copy. The directory row for sgit.ai now says open.",),
    ('v0.6.22', '2026-09-29', 'git c35c0e9e',
     "NAMED PERSON, PROVABLE RECORD. A proposed partnership page for Authentitas, whose briefing "
     "paper The Accountability Market (October 2026, shared on LinkedIn on 29 September) argues that "
     "every repair of accountability in four thousand years has been a named person and a record "
     "that can be proved, and that almost no money has gone into proving the person. The page and an "
     "eight-page briefing PDF, designed as an infographic and rendered from HTML, set the paper "
     "beside what this site and RiskMandate have built: the evidence ladder joined to their four "
     "questions into one ladder of five rungs, the story vault as Article 50's second door, the "
     "licence to operate read against the six requirements of accountability (three met, three that "
     "Authentitas supplies: identity that cannot be bought, an independent witness, a held identity "
     "for the pseudonymous), the insurers as the forcing function on both pages, the operator line in "
     "every Agent Contact file as a name nothing proves, and three integrations. Public material "
     "only, quotations attributed, no conversation yet. The PDF's eight pages are the page's figures. "
     "Partnerships index row added.",),
    ('v0.6.21', '2026-09-29', 'git cac3e346',
     "AGENT CONTACT V0.1, AND PCI DSS AS A GRAPH. Two things. FIRST, the protocol every site in the "
     "network is adopting for signed, encrypted mail between site agents is published here as its "
     "canonical copy: /docs/agent-contact.html carries the draft as approved on 29 September, the "
     "review that preceded approval with its four recommendations, and the owner's decision to keep "
     "the append token public and treat abuse of it as a canary, recorded in the owner's own "
     "reasoning. Two JSON schemas sit under /docs/schemas/. This site now publishes its own contact "
     "file at /.well-known/sgit-agents.json (no identities yet, inbox not open, and it says so), a "
     "network directory at /agents/ that checked all 32 sites on the day (none publishes a contact "
     "file yet; six have an unrelated /agents/ page), an Agents link in the header, a line in "
     "llms.txt, and a .nojekyll file so the dot-directory is served. SECOND, a new reference vault: "
     "PCI DSS v4.0.1 as a semantic graph, first pass, 232 nodes and 367 edges, every edge a verb "
     "with a named inverse, every node with provenance. The standard's text is not in it, because "
     "PCI SSC's licence does not allow reproduction: identifiers, the twelve quoted titles and our "
     "marked paraphrases are, with five public sources fetched and hashed, and a tool for licence "
     "holders to add the text locally. Published with a derived read key, classified before use, "
     "verified by a read-only clone identical to the source and an all-zeros control that failed "
     "downloading the index. Five screenshots from the clone served locally, with a hashed manifest. "
     "One thing to record: while deriving the keys, the CLI's derive-keys output was echoed once "
     "into the build session's transcript, including the write key; it reached no file in this "
     "repository, the staged-diff scan for it is now part of the release checks, and the vault "
     "holds nothing that is not already public.",),
    ('v0.6.20', '2026-09-29', 'git b346378d',
     "THE READER WAS ALWAYS THE PRODUCT. New article from a voice memo that set out the author's "
     "history of how news got into this mess, in four eras, with an instruction to check it and "
     "correct it. It was corrected in four places and the corrections are the article: the reader "
     "became the product with the penny press in 1833, not with the web, and advertising was 82% of "
     "American newspaper revenue in 2005; the web took the monopoly under the model, the local toll "
     "bridge whose 20 to 30% margins paid for reporting, with classifieds falling from $19.6bn to "
     "about $6bn in nine years; the platforms made the reader measurable and the publisher a "
     "tenant, with the duopoly at 54.7%, Facebook referrals down 58%, false news 70% more shared "
     "and newspaper newsrooms down 57%; and AI removed the traffic, after which circulation revenue "
     "overtook advertising in 2021, the industry going to rent rather than back to the reader. The "
     "road not taken is dated: 402 Payment Required reserved in 1997 and still unused, DigiCash "
     "bankrupt in 1998, Szabo and Shirky in 1999 and 2000, Zuckerman's default-model line, and the "
     "evidence that people pay when paying is easy, a million songs in a week in 2003 and five "
     "million paid newsletter subscriptions in 2025. Two figures: the four-era grid and the money "
     "flows, ending with the story graph. Thirty-two sources, every external link fetched; the "
     "Atlantic and Science pages refused fetches, so the pop-up apology is cited via NBC News and "
     "the Twitter study via MIT News. Where the memo made an observation no study supports, the "
     "spreadsheets and notebooks, the article says so.",),
    ('v0.6.19', '2026-09-29', 'git ffbbdfa9',
     "SIX AGENTS, ONE INBOX. New article from a voice memo written as a brief to the author's own "
     "agents, and from the debrief of the agent that found the attachments field. The setup: a "
     "dedicated Workspace seat, Claude seat and GitHub account per agent identity, six roles "
     "(reader, mailbox, inbox, CRM, dev team, site editor), and a policy table with the column "
     "that matters, how each rule is enforced today. Three findings. The dedicated account does "
     "more than segregate: it makes each agent its own organisational unit, where Google's "
     "Restrict delivery, attachment compliance and app scope limits become per-agent enforcement. "
     "The first exception arrived before the first policy: the reader that must never reply must "
     "reply to the founder, which is an authentication problem the append-lane signature registry "
     "already addresses. And the policy that assumed the Gmail connector could not send "
     "attachments was wrong: create_draft and update_draft take an attachments array, proven with "
     "a 17 KB signed PDF, while the vendor's two documentation pages disagree about whether the "
     "connector can send at all, both quoted and dated. The Gmail API puts drafts and sending in "
     "one scope, so drafts-only cannot be a scope rule and lives one rung down. Two figures: the "
     "matrix, coloured by enforcement, and the four-rung ladder. Roles are named, mailboxes are "
     "not. Fifteen sources, every external link fetched on 29 September.",),
    ('v0.6.18', '2026-09-28', 'git 6a06cb06',
     "THE TOKEN BILL NOBODY IS SENDING. New article from a voice memo and a LinkedIn post: a media "
     "analyst's week of Cloudflare logs showed AI answer engines fetching a small site 16,000 times "
     "and sending ten readers back. The usual reading is a tragedy of the commons, and the article "
     "makes a second one: those fetches are a cost to the fetcher too, every one a page of HTML "
     "parsed and tokenised, more than half of them re-fetches of unchanged pages, on a web where "
     "ninety per cent of crawled pages are unique and defeat every cache. Measured here for the "
     "first time: the markdown twin of a page on this site is 62% fewer tokens than its HTML, over "
     "all 183 pages that have twins (1,290,795 to 488,625, cl100k_base), against Cloudflare's own "
     "example of 81%. The ladder of easy-to-read has four rungs, a markdown twin, a date and a hash, "
     "frozen hashed sources and a typed graph, and every rung already exists on this site. Every "
     "payment rail built so far prices the content and none prices the format; the proposal is a "
     "share of the provider's saving, paid in money or in the provider's own tokens, with the "
     "News Corp deal's cash-and-credits as the precedent. The per-site arithmetic is small and the "
     "article says so, lists six objections including the analyst's own, and names the three "
     "numbers in the providers' logs that would settle it. Two figures, drawn as SVG. Twenty-nine "
     "external sources, every one fetched and checked; the analyst's newsletter returned 403 so only the "
     "LinkedIn post is cited. LICENCES ON EVERY ARTICLE: the license field from v0.6.17 is now set "
     "on all fourteen articles, and the seven that had no notice in their body have one, worded so "
     "it covers the article's own text and leaves quoted material and linked sources under their "
     "own licences.",),
    ('v0.6.17', '2026-09-28', 'git bcb99f91',
     "THE SUPPLY CHAIN ARTICLE CREDITS THE PROGRAMME THAT PROMPTED IT. The article is about to be "
     "sent to the panel and producers of the BBC Radio 4 programme it came from, and its opening "
     "paragraph criticised them without naming them. Three changes from the author's change "
     "request: the opening paragraph now names The Cost of Food, The Food Programme recorded live "
     "at the Abergavenny Food Festival, and says the usual tools matter before adding one more; a "
     "credit note follows it, naming the presenter, panel and producer, linking the episode, and "
     "crediting Sustain's Unpicking Food Prices, which one panellist helped write; and a CC BY 4.0 "
     "notice closes the article. Two softenings the author agreed to: the second paragraph now "
     "addresses the wider food debate rather than that conversation, and the abstract's first "
     "sentence follows the new opening instead of repeating the old criticism. The rest of the "
     "article is unchanged. THE BUILD LEARNED TWO ARTICLE FIELDS: updated, shown in the byline "
     "next to the original date, and license, a URL that becomes the page's link rel=license and "
     "the JSON-LD license, so the notice in the body and the metadata come from one line. Six other "
     "articles carry a CC BY 4.0 sentence in their body but not the field; they are listed for the "
     "author and left alone in this pass.",),
    ('v0.6.16', '2026-09-27', 'git 4030f39f',
     "THE OPEN-MODELS PASSAGE OF THE SUPPLY CHAIN ARTICLE, SAID PROPERLY. The author read the "
     "article and found the point about open-weight models understated: it is not that Chinese "
     "models are cheap, it is that economies building on open weights have an under-reported "
     "advantage, because a near-frontier model can run inside a company's own environment and be "
     "innovated on, and the model is one small, very important part of a working solution. The "
     "passage now says that, with the two standard objections answered as the author answers them: "
     "air-gapped operation and ordinary security controls handle call-home and data leakage, and "
     "the harness and workflow handle bias, which closed models carry too. Source added: the "
     "US-China Economic and Security Review Commission's Two Loops paper (March 2026), whose key "
     "findings make the same argument from the other side, read from the PDF itself rather than "
     "a summary. The In short bullet and the summary follow.",),
    ('v0.6.15', '2026-09-27', 'git e783118b',
     "A SUPPLY CHAIN OF VAULTS. New article from two voice memos recorded after a programme on "
     "food security: the food chain is a series of hops that keep their own records, mostly in "
     "spreadsheets, the big buyer is the one party with real systems and names the price once it "
     "holds enough of a farm's output, and all of that is logistics, which is what generative AI "
     "used the way this site uses it is good at. It describes a chain of encrypted vaults joined by "
     "append lanes and a typed graph, the use-GenAI-not-to-use-GenAI pattern with the villagers "
     "and town planners from the SaaS article, the hypothesis that this lowers prices, and two "
     "dogmas: that falling prices are always bad, and that sharing is giving things away. Three "
     "diagrams. RESEARCH, AND WHAT DID NOT SURVIVE IT: the memo credited the buyer-power argument "
     "to a name the transcription mangled; the mechanism it describes is Giblin and Doctorow's "
     "Chokepoint Capitalism, and the article cites that and Doctorow's Enshittification. A search "
     "summary attributed to CSIS a venture partner's estimate that 80% of US startups build on "
     "Chinese base models; the CSIS page does not contain it, and the claim was dropped before "
     "publication. The ONS 'changing price of everyday goods' page is cited at its "
     "www.ons.gov.uk address because the visual.ons.gov.uk one no longer resolves. Every other "
     "figure was read from its source: Sustain's food pound, the FAO Food Loss Index, the GCA 2025 "
     "survey, the fair dealing regulations, McKinsey's 2021 early-adopter gains (stated as large "
     "firms' gains, not a farm's), the BIS history of deflations, the Commission's data figures, "
     "DeepSeek's own GPU-hour count, and Apertus. The article says where the evidence stops and the "
     "hypothesis starts, and that nobody has yet run a season through vaults."),
    ('v0.6.14', '2026-09-27', 'git 8f77a502',
     "THE FIRST SCREENSHOT ON EVERY VAULT PAGE NOW OPENS THE VAULT. v0.6.13 did this by hand on "
     "one page, wrapping the figure in a link; the founder asked for it everywhere a vault exists, "
     "since the natural next step from a picture of a vault is the vault. Done in the build, not "
     "in 37 content files: on a vault page the build finds the page's own open-in-the-official-UI "
     "link and marks the first figure with it (data-href), and shots.js, which creates every "
     "image, wraps that image in the link and labels it. The figure's own markup is untouched, "
     "which matters because on 20 pages the first screenshot sits inside a walkthrough grid whose "
     "CSS addresses figures as direct children; wrapping the figure would have broken the grid. "
     "The caption gains 'Click the image to open the real one'. 34 of the 38 vault pages carry "
     "screenshots, and all 34 are linked; the other four have no figures to link. The two "
     "synthetic-users pages were missed by the first pattern because their links carry a language "
     "path before the fragment, and the pattern now allows one. The gallery index has no single "
     "vault and is skipped. The evidence-dispatch page drops its hand-written wrapper and "
     "gets the same treatment as the rest."),
    ('v0.6.13', '2026-09-27', 'git b6ee8603',
     "A SECOND REVIEW OF THE EVIDENCE DISPATCH PAGE, AND A LOADER THAT RAN TWICE ON 33 PAGES. The "
     "vault's agent reviewed v0.6.12 and found five things, all confirmed before changing anything. "
     "(1) The claims screenshot showed C02 to C06 while its caption said C01 to C03, because the "
     "capture script inherited the previous view's scroll; the new capture starts cleanly at C02 "
     "and the caption says what it shows. (2) shots.js loaded twice: the inline loader on every "
     "vault page fetched it, and the build injected a second, versioned copy because its guard "
     "looked for the literal assets/shots.js while the inline code built the path from parts. The "
     "per-figure guard hid it, but each run added an IntersectionObserver and a set of print and "
     "keyboard listeners. Fixed three ways: 32 pages plus the DSIT page no longer load it "
     "themselves, the build's guard now recognises any mention of shots.js, and shots.js refuses "
     "to start twice. The review saw it on one page; the same pattern was on 33. (3) The "
     "cross-agent note named one agent and said nothing else passed between them; it now "
     "describes the coordinating assistant and three specialist agents, the owner who commissioned "
     "the work and carried the key and two reviews between the environments, and says that this "
     "is the owner's account, not something we verified. v0.6.12's entry is amended in place. (4) "
     "Crops: graph and correction impact now start at the top of the controls and inspector "
     "panel, the article and questions at clean section boundaries, and the correction caption "
     "explains fourteen downstream records against sixteen direct relationships. (5) The vault's "
     "readiness numbers are tied to that report's own timestamp and commit count, apart from our "
     "scan of the current files, and a capture manifest (images/captures.json) gives every image's "
     "SHA-256, time, viewport, commit and state. Asked for by the founder at the same time: an "
     "Open the vault button under the lead, and the main image is now a link to the vault, so the "
     "natural next step from the picture is the real thing."),
    ('v0.6.12', '2026-09-27', 'git 65da1140',
     "THE EVIDENCE DISPATCH PAGE, CORRECTED BY THE AGENT THAT BUILT THE VAULT. The vault's author, "
     "another company's agent, refactored the vault's folders (v0.5.1) and reviewed our listing. "
     "Most of the review was right, and the page is rewritten on it: we had written that every "
     "claim walks back to hashed source bytes, when six selected excerpts have byte anchors and "
     "full responses are not bundled; we had counted four desks where there are three authoring "
     "desks and four audience briefings; we had said a correction reaches every dependent story, "
     "when the view traverses declared dependencies and cannot prove all were declared; and the "
     "refactor had moved every path the page named. Where we went further than the review: the "
     "v0.5.1 contents were re-audited rather than relabelled (235 files at commit c2f6a15e3a01, "
     "same result), the negative control is now described by its actual failure (the all-zeros key "
     "cannot find the vault index, so clone fails before any decryption), and the timeline keeps "
     "the vault's own title in quotation marks while describing four lanes as lanes. Screenshots "
     "were recaptured from v0.5.1 with the graph mode, focus, event and scroll set by the script: a "
     "fitted graph at 2x, a new correction-impact view, the August discovery selected, S01's "
     "dossier with its hash fields, and an article section with its claim and source links. "
     "catalogue_derive.py now reads .vault/app.json as well as app.json, which is why v0.6.11's "
     "page said the vault had no app manifest. The v0.6.11 entry is amended in place. The page now "
     "also says what this case shows about the workflow: one company's agent built, wrote and "
     "refactored the vault, another's audited and listed it, and nothing passed between them but "
     "the vault, its read key and a review [CORRECTED IN v0.6.13: this is the owner's account, relayed; the owner commissioned the work and carried the key and two reviews between the environments, and the vault side was a coordinating assistant and three specialist agents, not one agent]."),
    ('v0.6.11', '2026-09-27', 'git 2719284a',
     "THE EVIDENCE DISPATCH: A NEWSROOM ON AN EVIDENCE VAULT, LISTED FROM ITS OWNER. The first "
     "vault on this site that sgit.ai did not build. Its owner sent a public read key and a "
     "readiness audit, and it is listed as the worked example of what newsroom.sgit.ai argues and "
     "graphs.sgit.ai describes: one real incident, an AI research agent's unauthorised access to an "
     "Australian government Medicare statistics portal, written up as six AI-authored articles and "
     "four role briefings over an eighteen-claim ledger, twenty-one source dossiers with frozen, "
     "hashed anchors [CORRECTED IN v0.6.12: six selected excerpts have hashed byte anchors, not every source; and the briefings are four audience briefings, not desks], a four-clock timeline and a typed graph of 87 nodes and 233 edges in seven "
     "vocabularies, where a changed claim lists the stories that depend on it. The listing was run "
     "through the same method as our own vaults, without relying on the owner's audit: the key "
     "classified public and read-only; a read-key clone gave 233 files; our scan found only the "
     "vault's own public key and one false positive (an article slug containing sk-); the all-zeros "
     "control opened nothing; the app rendered in the official vault UI with no page errors. The "
     "page says what the vault says about itself, including that it has no human editorial or "
     "legal sign-off, and states the case only as its sources do. Two things differ from our own "
     "vaults and are said on the page: the app declares read, link and download permissions, and "
     "its manifest lives at .vault/app.json, which the derivation script does not look for. Added "
     "to the fractal graphs page's table, and filed as an ask to newsroom.sgit.ai and "
     "graphs.sgit.ai to link it."),
    ('v0.6.10', '2026-09-26', 'git 70e69df2',
     "USING SGIT FROM CLAUDE ON A TEAM OR ENTERPRISE PLAN. A brief from mailbox.riskmandate, written "
     "after an incident the same day: an agent in a Claude Cowork session could pip install sgit-ai "
     "and could not reach sgit.ai, because the organisation's sandbox allowlist had *.sgit.ai and "
     "not the apex, and the proxy's CONNECT 403 looked like an outage. New how-to at "
     "/docs/how-to/claude-team-egress.html: who can change it (an Owner), which domains, a check to "
     "paste into the session, and a symptom table. Claims about Claude's settings were checked "
     "against Anthropic's two help-centre articles before publishing, and one was softened: the "
     "brief saw the change reach a running session, Anthropic says settings apply to new sessions, "
     "so the page says start a new one. The apex-versus-wildcard behaviour is labelled observed, "
     "because the help centre does not say. A Network requirements note is now in llms.txt and on "
     "the agents guide, and llms.txt's append-lane answer names the only host with the routes. The "
     "brief's three CLI findings were reproduced where possible on sgit-ai v0.16.2 and filed as "
     "SGit-AI__CLI issues 2, 3 and 4: doctor says no remote configured in a read-only clone "
     "(reproduced; the after-push case is reported, not reproduced), clone --help omits the "
     "prefixed read keys the site publishes (reproduced), and history log prints no commits in a "
     "read-only clone of a four-commit vault (reproduced)."),
    ('v0.6.9', '2026-09-26', 'git 9717a210',
     "THE NEWSROOM'S FIRST BRIEFING, ANSWERED IN ONE RELEASE. sgit.newsroom.sgit.ai, the newsroom "
     "that reads every site in the network daily, left sgit.ai a briefing: a relayed write-up of "
     "two-way append-lane messaging between agents, a proposal, four signals and eight loose ends. "
     "(1) THE WRITE-UP is published at /docs/append-lane-messaging.html, verbatim; the sha256 of its "
     "text matches the hash the newsroom gave for its copy. Its section 11 listed eleven places the "
     "docs differed from the server, found by two teams using the lanes in anger. Ten were real and "
     "are fixed, each with a dated note: the append routes exist only on dev.send.sgraph.ai, and "
     "every curl example pointed at send.sgraph.ai; auth failures are 404 HTML, and the guide said "
     "403; configure needs the access token and REPLACES the anchor list; fetch and mark-processed "
     "need the lane named; fetch serves pending files only; the payload is encoded twice; the "
     "write-key derivation strips a prefix first; the signature covers the inner ciphertext only; "
     "decrypt reports a label, not a fingerprint. One did not hold: no page here gave the enum key "
     "for purge. Section 12's recommendations belong to the CLI and the server and are filed as an "
     "open cross-team ask. (2) COUNTS ARE COMPUTED NOW. The vaults page still opened with "
     "thirty-one while listing 36, and the business plans page described two plans while listing "
     "five: numbers typed by hand and left behind by later releases, the failure this log's own "
     "rule says to avoid. Seventeen current counts on twelve pages are now tokens the build fills "
     "from vaults.json and from the plans table; dated records keep the number that was true when "
     "written. (3) A CORRECTION TO v0.2.98, found because the newsroom could not reconcile it with "
     "v0.3.0: recounted from git, v0.2.98 left 102 legacy-prefixed read keys in 27 tracked files, "
     "93 in 26 content pages and 9 in one build script, not 99 across 27 pages. v0.3.0's 102 was "
     "right. The old entry is amended in place and says so. (4) STALE AND WRONG PAGES: two asks on "
     "the briefs index were closed long ago elsewhere (riskmandate.ai built the interview page in "
     "its v1.34.2; the CLI prefix bug closed in our v0.3.0); the interview brief now cites "
     "riskmandate.ai's earlier voice feedback prompt, which it should have; the key management call "
     "names the three plans that depend on it; the Risk Acceptance vault's README said eight weeks "
     "for a six-week replay and is fixed in a fourth commit; and the Sovereign AI page said "
     "contracts run to five million pounds over 12 to 24 months, which no official page we could "
     "find says: the scheme's competition guidance, read today, gives 250,000 pounds to 10 million, "
     "most at 1 to 3 million, and numbers the agent challenge 4. (5) The newsroom's reader's log, "
     "chat and relay pattern is published as a build brief, not adopted. A response page answers "
     "every item, and two loose ends stay open and waiting on the founder: partnership contacts, "
     "and documenting the Azure and Google Cloud deployments."),
    ('v0.6.8', '2026-09-24', 'git fd203e01',
     "COMPANY X-RAY: A BUSINESS PLAN, WITH ONE COMPANY X-RAYED. New vault page and a new row in "
     "the business plans for founders. The service reads a company's own documents together: the "
     "customer drops them into an encrypted vault, agents run a catalogue of twelve analyses, a "
     "person reviews, and within five working days the customer gets their questions answered, a "
     "one-page board pack, findings that each name the file and row they rest on, and a Claude "
     "setup to keep asking. No connectors, no integrations. The vault holds an invented company "
     "X-rayed end to end, fourteen findings labelled read, computed or inferred, and a script that "
     "re-runs all 46 figures from the documents. Four levels from £50 to £1,500 reuse "
     "RiskMandate.ai's pricing pattern. Read key published on purpose; vault key in the gitignored "
     "tier; all-zeros control empty. Thirty-six published vaults."),
    ('v0.6.7', '2026-09-24', 'git 63f3e72c',
     "A BRIEF FOR RISKMANDATE: INTERVIEW PAGES, AND A CHATGPT VOICE PROMPT. New build brief in "
     "/docs/briefs/, and an open cross-team ask. It defines a reusable interview page for "
     "riskmandate.ai: a link sent to one person, a prompt they paste into ChatGPT and continue in "
     "voice mode, about twenty minutes of interview, and a structured written summary they send "
     "back. The first page is for a founder strong in UK events, marketing and content that "
     "spreads, testing the value proposition, the name, who to reach first, events, content angles "
     "and a thirty, sixty and ninety day plan, with candid criticism asked for explicitly. The full "
     "prompt is in the brief, describes RiskMandate only in the words riskmandate.ai already uses, "
     "and is ready to send today. A later step proposes returning summaries through a write-only "
     "append lane instead of email."),
    ('v0.6.6', '2026-09-24', 'git 23096e71',
     "LESSON LOOP: A BUSINESS PLAN FOR COACHES. The thirty-fifth vault (3s9q7zl7, 27 files) is a "
     "business plan for padel coaches, and any teacher with students: capture the coach's picture "
     "of the player at the end of the lesson, when it is most complete, as a voice memo; an agent "
     "turns it into a lesson note and three points for the player's next games, in the player's "
     "own data vault; the player adds match notes and clips; and the next coach, in any club or "
     "country, starts from a two-minute briefing. The app replays one invented player's record "
     "across four lessons with three coaches in London and Lisbon, with the raw memos, the notes, "
     "the player view, briefings, and a themes matrix showing what each coach saw. The plan sets "
     "out four phases (capture first, nothing else until it works), the two-vault architecture "
     "with a write-only lane per coach, what exists today, pay-on-demand credits instead of a "
     "subscription, new coach income (remote reviews, drill plans, follow-ups, content), the club "
     "variant and its tension, other teaching domains, risks (children, other players in clips, "
     "undisclosed commissions) and a calculator that shows a small business at 200 coaches. The "
     "coach-memo prompt is ready to paste for phase one. Listed on the business plans page."),
    ('v0.6.5', '2026-09-24', 'git 138dbe4e',
     "EVERY RISK IS ALREADY ACCEPTED, AND A COMPANY TO RUN THE LOOP. A first-person foundation "
     "article for readers new to risk acceptance: no deny button; accept, fund or fix, with silence "
     "escalating; the interval as the decision (the risks.sgit.ai ladder, 1 hour to 6 months); "
     "accepted versus acceptable, with the EU AI Act's Article 9(5) quoted and scoped to providers "
     "of high-risk AI systems, which never defines acceptable; every risk with a boss and every path "
     "to the board; fractal risk registers from the board to the bytes; established by facts and "
     "ended by facts; a vault per material risk as the evidence pack of governance (proposed here); "
     "twins as the direction (designs, not built); why executives resist; and why it fits beside "
     "every GRC platform. Three new diagrams plus Risk Graph Explorer views. The thirty-fourth "
     "vault, Risk Acceptance Office (odn10gfp, 32 files), replays one invented risk over six weeks "
     "with a hash-chained decision record verified in the browser and a side-by-side of the register "
     "row, and carries the business plan: operating model, the GRC gaps, services priced per "
     "material risk, a calculator, go-to-market starting from the resistance, ninety days, risks "
     "and open questions. Research found four places where the published method disagrees with "
     "itself; the plan picks a position on each and says so. Listed on the business plans page."),
    ('v0.6.4', '2026-09-24', 'git 9f174c35',
     "PARTNERSHIPS WITH THE CLOUDS AND THE AI PROVIDERS, AND A BRIEF FOR RISKMANDATE. Sixteen new "
     "pages under /partnerships/, written from the vault side and each one forwardable on its own. "
     "The cloud platforms hub says what sgit needs from a cloud (mostly storage, a little compute), "
     "where it stands on each, honestly (Docker everywhere; AWS templates in beta; Google Cloud "
     "planned; Azure deployed by the founder but undocumented; the S3 mode targets Amazon S3 and "
     "S3-compatible stores are untested), the two partnerships in one (vaults in the cloud's "
     "environment, and services on top of vaults), and one page each for AWS, Azure, Google Cloud, "
     "IBM Cloud, the European clouds (OVHcloud, Scaleway, Hetzner, IONOS, STACKIT), DigitalOcean, "
     "Rackspace and Netlify. The AI providers hub sets out three meeting points (agents read and "
     "write vaults through a connector, vault apps call models without a key, vaults carry the "
     "agent's work) and one page each for OpenAI, Anthropic, Mistral AI, Google Gemini, OpenRouter "
     "and ElevenLabs. Every provider fact is from the provider's own pages, researched today; "
     "credit amounts are left out because several could not be confirmed. Research corrected two "
     "first drafts: this site is served from GitHub Pages, not AWS, and the server's S3 mode is "
     "only proven on Amazon S3. The brief for RiskMandate.ai, built on a survey of its 79 pages "
     "and 16 policy vaults, asks for the risk side of every partnership as two behaviour policies "
     "and a delta, and for sgit written up as a control against GDPR Articles 32, 25, 34(3)(a), "
     "28 and 17."),
    ('v0.6.3', '2026-09-24', 'git ae3f8714',
     "BEFORE YOU GIVE AN AGENT A CONNECTOR, GIVE THE CONNECTOR A TWIN, AND BUSINESS PLANS GET "
     "THEIR OWN PAGE. A first-person article argues that a twin of the connector, a journal of "
     "every request and response an agent makes, appended to a write-only lane, processed later and "
     "replayed into the inbox and calendar as the agent saw them, is the minimum requirement for "
     "deploying an agent with provenance, explanation and undo. Every claim about Gmail and Calendar "
     "is Google's own and linked: the API delete 'cannot be undone', Undo Send is an interface "
     "feature, trash keeps 30 days, the administrator's bulk restore 25, Vault 'isn't designed to be "
     "a backup or archive tool' and keeps one Calendar revision a day. Research corrected two first "
     "assumptions (Vault has covered Calendar since November 2023; the admin audit log does record "
     "some earlier values) and the text says so. The thirty-third vault, Connector Twin (7tkvspwp, "
     "31 files), opens on a working replay of an invented seventeen-call Gmail and Calendar session: "
     "a slider rebuilds the inbox and calendar at any step, before and after for every write, a "
     "graded revert plan, what the agent said against what the journal shows, and a hash chain "
     "verified in the browser, which ran inside the vault host's sandbox. Behind it, the business "
     "plan: facts with sources, architecture, three capture modes, packages per agent, a "
     "calculator, go-to-market, ninety days, risks, open questions, the journal specification, "
     "revert rules and three prototypes. New page /startups/business-plans.html gives the plans "
     "their own home, listed in the Why menu, with Agent as Webmaster and Connector Twin, how to "
     "take one, and the offer to build them with partners."),
    ('v0.6.2', '2026-09-24', 'git 42d40e9f',
     "A CALL FOR COLLABORATION ON VAULT KEY MANAGEMENT. New page in Partnerships, "
     "/partnerships/vault-key-management.html, written to be sent to password managers, identity "
     "providers and platform credential managers who have never heard of sgit. It opens with the "
     "short version, then what we are looking for in order of preference (something that already "
     "exists, a joint pilot, an open specification), the requirements in plain terms (browser first, "
     "key kinds kept apart, release only on the user's approval, end-to-end sharing, names in plain "
     "words that are addresses and never keys, revocation, scoped keys for agents), sgit in two "
     "minutes, three new diagrams (keys travelling by hand today, what a vault is and where the key "
     "sits, a seven-step share-by-name flow), and then the detail: the key formats, why the "
     "word-based share token was removed in August (about thirty bits, recoverable in a tenth of a "
     "second on a GPU), why a vault of keys still needs a first key, and what exists against what "
     "does not. The partnerships index lists it as an open call."),
    ('v0.6.1', '2026-09-23', 'git 277b41b1',
     "THE DSIT AI RISK TOOLKIT PAGE STARTS WITH THE READER'S DECISION, AND PLAYS THE DECK. Rebuilt "
     "from a brief: the vault has moved on since 20 September (155 files, a use-case-led journey at "
     "v0.2.0 as the home page, reference edition v0.2.3, a decks/v2 deck 'A decision before a risk "
     "list'), and the page still opened on four worlds and 617 nodes. Now: an outcome headline, three "
     "actions (live vault, eight-slide walkthrough, PDF), the site-owned decks/v2 viewer mounted on "
     "the page reading manifest, slide source, styles, five screenshots and the PDF from the vault, "
     "a five-step 'what you can try', the vault embed, then the architecture material moved below "
     "with the four-world screenshots dated, a status table separating journey v0.2.0, reference "
     "v0.2.3, the research graph (941/4,735) and the reference graph (1,051/1,289) without merging "
     "them, the licences stated (OGL v3.0 for DSIT text; no licence assigned to the new code), and "
     "the read key, derived facts, audit and the dated 20 September snapshot in an expandable "
     "provenance section. VIEWER: keyboard navigation (arrows, PageUp/Down, Home, End), a live "
     "region announcing the slide, aria-current on the slide list, titled frames, focus-visible "
     "outlines, and a counted 'N screenshots could not be read' status instead of a plausible-"
     "looking gap. Verified through the mirror: 8 slides walked, 0 unresolved images, PDF bytes "
     "match SHA-256 36f9e16c…, parse frame allow-scripts only, render frame no scripts.",
    ),
    ('v0.6.0', '2026-09-23', 'git ec7bcad8',
     "THIRTY-SECOND VAULT, AND THE FIRST BUSINESS PLAN PUBLISHED FOR SOMEBODY ELSE TO RUN. Agent as "
     "Webmaster (ikrqeu5t): give small businesses a website they can change by asking, with an AI agent "
     "as the webmaster and GitHub Pages as the host. The vault carries the plan as a single-page app "
     "with a unit-economics calculator, the same plan as eleven markdown documents, three drawn "
     "diagrams, three invented customer sites and the operator's sales site as standalone mock-ups, "
     "and the prototypes to serve the first customer (the agent's playbook, the GitHub Pages setup, "
     "six worked change requests). permissions {}. Published under the method: classified, derived "
     "one-way, audited from a read-key clone (nothing found), negative control run, facts derived. "
     "The startups section gains 'Business plans to build on'. THE PUBLISH FOUND TWO CLI BUGS: the "
     "auto transport treated a fresh vault's object 404 as 'no live API' and flipped push to the "
     "read-only static transport; and push, pull, fetch, status and delete ignored --transport. "
     "Both fixed in the CLI repository with a pinning test. Vault counts across the site move to "
     "thirty-two; measured estate figures keep their 21 September date.",
    ),
    ('v0.5.9', '2026-09-23', 'git 62148934',
     "THE SOVEREIGN AI PAGE SAYS WHAT IT IS IN ITS TITLE. A reader landing from a forwarded link "
     "had no way to know this was a proposal from sgit.ai's side. Title is now 'A proposed "
     "partnership between sgit.ai, RiskMandate.ai and UK Sovereign AI', the eyebrow names both "
     "parties and calls it proposed, the lead opens by saying so and introduces the two products "
     "in a clause each before introducing the fund. Index link, page title and About cross-link "
     "follow.",
    ),
    ('v0.5.8', '2026-09-23', 'git ff7de798',
     "THE SOVEREIGN AI CASE GETS ITS ANCHORING, FROM THE FOUNDER'S OWN MEMO. New section on the "
     "partnership page, stated in the first person because only the founder can supply it: thirty "
     "years in the UK as practitioner, CISO for UK companies and founder; one UK exit; The Cyber "
     "Boardroom Limited, UK-registered and trading for almost two years, as the commercial vehicle "
     "behind sgit.ai and RiskMandate.ai; the intent to grow more UK companies on the technology. "
     "Plus the sovereignty argument: no sovereign AI without open source, because a closed champion "
     "is one acquisition away from not being sovereign, while an open substrate cannot be bought "
     "out from under the country that runs on it. The honest section now says anchoring is stated "
     "first-hand rather than documented here; the lead and the fit table carry the argument; the "
     "About page gets the same facts in one row.",
    ),
    ('v0.5.7', '2026-09-23', 'git 3aa131f4',
     "NEW SECTION: PARTNERSHIPS, THE BUSINESS CASE MADE IN PUBLIC. /partnerships/ states the "
     "method (start with what they are trying to do, public material only, evidence not pitch, the "
     "fit stated precisely, what is not public marked) and carries the first case: UK Sovereign AI. "
     "Their mission in their words (the sovereign edge, five frontiers, the offer beyond investment, "
     "the R&D procurement scheme and its four challenge areas, the anchoring test), what sgit.ai and "
     "RiskMandate.ai have published, a fit table that maps each stated priority to vaults that can "
     "be opened now (Licence to Operate, Agentic Browser Isolation, Risk Mandate, AIUC-1 "
     "conformance, the DSIT toolkit, Regulation Graph, the performance page), three concrete "
     "partnership shapes sized to their instruments, and a section on what the page cannot tell "
     "them (anchoring, certification, stage, the partial fit). Written to be forwarded to anyone "
     "who knows somebody there. In the Why menu.",
    ),
    ('v0.5.6', '2026-09-22', 'git b4213542',
     "THE BYLINE NOW LINKS TO A PROFILE PAGE ON THIS SITE. New /about/index.html, About the "
     "author, in the Why menu: the record (the companies, OWASP, the O2 platform), the signed "
     "articles here, writing elsewhere (docs.diniscruz.ai, LinkedIn, open-source.sgit.ai's "
     "fuller version of the page, wardley-maps, graphs, newsroom, GitHub), interests declared, "
     "and how to reach or correct the author. author_url may now be site-root relative; the "
     "byline renders it as a relative link and the JSON-LD Person gets the absolute URL. The "
     "four signed articles point at the new page instead of LinkedIn.",
    ),
    ('v0.5.5', '2026-09-22', 'git 49aa9407',
     "ARTICLES GET A BYLINE. The author noticed that the news article is written in the first "
     "person and his name appears nowhere on it, on a site whose argument is provenance. New "
     "optional frontmatter, author and author_url, rendered as 'By <name>' with the link first on "
     "the date line, emitted as a schema.org Person in the page's JSON-LD, and carried in "
     "articles.json. The build refuses an author without a URL, because a byline without a link "
     "is a name, not provenance. Applied to the four first-person articles (news, SaaS, startup "
     "model, fractal semantic graphs); the site-voice articles stay unsigned.",
    ),
    ('v0.5.4', '2026-09-22', 'git 73408357',
     "THE NEWS ARTICLE, THIRD PASS: THE FRACTAL SEMANTIC GRAPH WAS MISSING. The author read it "
     "halfway and noticed the piece said graph without saying which kind. Two new sections: the "
     "graph is fractal and meaning comes from connectivity (edges are verbs, relates_to is banned, "
     "every node opens into its own world, bridges not merges, provenance comes free when the leaf "
     "is a word tied to a byte range and a hash, corrections propagate, the empty doors are "
     "knowledge too), and the ladder stated once: trust through provenance, provenance via "
     "evidence, evidence is bytes with a hash, and every rung can be sold because none is "
     "asserted. A fourth diagram draws a story zoomed through three altitudes and sideways into a "
     "source's identity world. Links to the FSG page and graphs.sgit.ai; summary and executive "
     "summary updated to match.",
    ),
    ('v0.5.3', '2026-09-22', 'git 1ddfae52',
     "THE NEWS ARTICLE, SECOND PASS, ON THE AUTHOR'S NOTES. The first pass described the problem "
     "well and the solution thinly. Now: an executive summary section up front with the eight key "
     "ideas; a section on why advertising and subscriptions are both bad for the reader (product, "
     "hostage); the objective stated once, a commercial model that rewards investigative journalism "
     "so usage funds it and it earns the usage, drawn as a third diagram against the race to the "
     "bottom; the two clocks compressed, since that part is known; and the five things to sell each "
     "given their mechanics (read key, licence to transform, evidence vault per licensee, the cut "
     "not the content, the two prices and freshness of the verification API). The word prose is "
     "gone from the text and the diagrams, replaced by content or the words, because the readers "
     "this is for are the people who write it.",
    ),
    ('v0.5.2', '2026-09-22', 'git fcd0de3a',
     "NEW ARTICLE: THE FUTURE OF NEWS IS THE STORY VAULT, NOT THE PAYWALL. Written from a voice "
     "memo, the seven future-of-news pieces on docs.diniscruz.ai (Feb to Jul 2025, dates kept "
     "on purpose), newsroom.sgit.ai, subscriptions.sgit.ai and pt.newsroom.sgit.ai, and the "
     "published vaults. Two clocks: Google traffic to publishers down 33% in a year and "
     "Cloudflare's billion 402s a day; the UK subscription regime brought forward to 1 Jan 2027. "
     "Reuters 2026: 17% pay, 71% of non-payers say nothing would persuade them. The counter-"
     "arguments to micropayments (Ball 2020, Guay 2026) are quoted and answered: they are about "
     "paying for prose. The story is a graph, the article is a projection, five things to sell, "
     "60/25/10/5, x402 rails, and an honest note that no sgit.ai site is wired to any rail. Two "
     "drawn diagrams, link-preview card generated, linked from the startups section.",
    ),
    ('v0.5.1', '2026-09-21', 'git a8f931aa',
     "THE GUARD SHIPPED IN v0.5.0 DID NOT GUARD THE CASE THAT MATTERS, AND THE NOTE FOR IT "
     "OVERCLAIMED. The author asked the right question: will this work for new articles that "
     "have an image? Tested rather than assumed, and the answer was NO. og_card() falls back "
     "to og/default.jpg when an article's card is missing, so a new article with a hero, "
     "published without running the generator, would build clean, validate clean, and ship "
     "with the GENERIC card. The v0.5.0 entry said the validator catches a forgotten "
     "generator run. It does not: it only checks that the file an og:image names exists, and "
     "the fallback always names a file that exists. Simulated to confirm, and the build "
     "printed no complaint while the new article's og:image pointed at the default. NOW THE "
     "BUILD REFUSES. After ARTICLES is loaded, any article whose body has a !shot figure but "
     "no og/<slug>.jpg stops the build, names every offender and prints the command to run. "
     "Same shape as the LLMS_SECTIONS check, which is the house pattern: the build refuses "
     "rather than guessing. og_card()'s fallback is now documented as safe precisely because "
     "that check has already run, so the only pages reaching the default are the ones meant "
     "to. AND release.sh RUNS THE GENERATOR, so in practice it is automatic: step 1 calls "
     "make_og_cards.mjs before the build when sharp resolves, and says so and skips when it "
     "does not, at which point the build's own refusal is what catches it. PROVED BOTH WAYS: "
     "a test article with a hero and no card stopped the build with its name and the fix; "
     "running the generator produced the card and the same article then built with its own "
     "og:image. So the answer to the question is: yes for a new article, automatically via "
     "release.sh, and if the generator is ever skipped the build stops instead of quietly "
     "shipping the wrong picture.",
     ),
    ('v0.5.0', '2026-09-21', 'git 7403bdaa',
     "SHARED LINKS HAD NO PICTURE, BECAUSE og:image WAS MISSING ENTIRELY. Reported from a "
     "LinkedIn compose box: pasting the SaaS article gave a title-and-domain card with no "
     "image. The head carried og:type, og:site_name, og:url, og:title and og:description, and "
     "NO og:image at all, with twitter:card set to the small 'summary'. So there was nothing "
     "for a crawler to show. AND THE OBVIOUS FIX WOULD NOT HAVE WORKED EITHER: every figure on "
     "this site is WebP, and LinkedIn's crawler does not read WebP. It drops the image without "
     "a word, which is the trap, because the page looks correct and the preview is silently "
     "plain. So the cards have to be JPEG. NEW TOOL, admin/build/make_og_cards.mjs, writes "
     "1200x630 JPEGs, the size LinkedIn documents for the large card, below which it can fall "
     "back to the small one. Every article's FIRST !shot becomes that article's card, parsed "
     "from the same markdown the page is built from so the two cannot disagree. Our heroes are "
     "wider than 1.91:1, so they are fitted rather than cropped, on a background sampled from "
     "each image's own corner, which makes the letterbox invisible on the cream covers. Eight "
     "article cards, 38 to 109 KB each, plus a drawn default card for the other 138 pages, "
     "carrying the wordmark, the tagline and pip install sgit-ai. THE HEAD now emits og:image "
     "as an ABSOLUTE url, og:image:width, og:image:height, og:image:alt, twitter:image, and "
     "twitter:card upgraded from summary to SUMMARY_LARGE_IMAGE, without which the picture is "
     "shown small or not at all. AND A GUARD, because the preview is invisible from the page "
     "itself and both failure modes are silent: validate.js check 3e fails the build if any "
     "page has no og:image, if it points at a .webp, or if the file it names does not exist, "
     "which is what would happen when somebody adds an article hero and forgets to run the "
     "generator. Proved by deleting the default card: 138 pages failed, and all passed again "
     "when it was restored. ALSO, SIXTEEN PAGE TITLES HAD AN UNBALANCED BRACKET, found because "
     "og:image:alt is built from the title and the homepage's read 'sgit (the encrypted git for "
     "humans and AI agents' with nothing closing it. Same em-dash-rewriter collateral as the "
     "article title template in v0.3.9: the paired rule opened a bracket where a dash had been "
     "and had no closing dash to convert. All sixteen closed, which fixes the browser tab and "
     "the preview alt text together. NOTE FOR SHARING: LinkedIn caches previews hard, so an "
     "already-posted link needs a pass through linkedin.com/post-inspector before it picks the "
     "image up.",
     ),
    ('v0.4.9', '2026-09-21', 'git e5263c4c',
     "THE PHONE MENU WOULD NOT SCROLL, AND TWO GRIDS OVERFLOWED SIDEWAYS. Reported from an "
     "iPhone: opening the top menu, you could not scroll it, unless you first scrolled the "
     "whole page, after which the menu moved. REPRODUCED AND MEASURED at iPhone 13, SE and "
     "Pixel 5 profiles. The open menu is 42 links and stands 1536px tall against a 664px "
     "viewport, with max-height none and overflow-y visible, inside a nav.site that is "
     "position:sticky. So the menu had no scroll of its own and the sticky bar pinned it at "
     "the top, leaving the last item, Security, 855px BELOW THE FOLD and reachable only by "
     "scrolling the page underneath it. Exactly the reported behaviour. THE FIX caps the open "
     "menu to the viewport and lets it scroll itself: max-height calc(100dvh - 7rem) with a "
     "100vh line above it as the fallback, overflow-y auto, and overscroll-behavior contain so "
     "the gesture stops at the end of the menu instead of chaining to the page. The 7rem is "
     "the worst-case closed bar, 92px on an iPhone SE where the row wraps rather than the 55px "
     "it takes when it does not, plus this element's own border, padding and margin, another "
     "17px, which on the first attempt left the nav 12px taller than the viewport. MEASURED "
     "AFTER: the menu scrolls 913px internally on an iPhone 13, 1009px on an SE, 850px on a "
     "Pixel 5; the page does not move at all (scrollY stays 0); the last item is visible; and "
     "the nav now fits inside the viewport on every profile. AND A SECOND BUG FOUND WHILE "
     "VERIFYING, unrelated to the menu and present with it closed: the homepage ran 28px wide "
     "at 320px (iPhone SE). Cause was grid-template-columns repeat(auto-fit, minmax(330px,1fr)) "
     "on .jobs, a hard floor wider than the ~285px of content box a 320px phone has, which "
     "does not shrink, it overflows. .netpick, the component behind the new fractal band on the "
     "home page and the startups section, had the same bug at 310px. Both now use "
     "minmax(min(Npx,100%),1fr), which collapses to the container below that width and changes "
     "nothing above it: desktop columns are identical at 1280. A comment records the rule, "
     "because the next person will reach for a bare minmax again. Four pages checked clean at "
     "320 and 390.",
     ),
    ('v0.4.8', '2026-09-21', 'git 99b37799',
     "A HERO IMAGE THAT ARGUES THE THESIS, AND THE FOUR WORDS THAT SUMMARISE IT BETTER THAN THE "
     "ARTICLE DID. The author made a cover for the inertia piece and revised it after review. "
     "The first version put both carts on the same railway track, which said same race, "
     "different speed; the revision has the newcomer OFF THE RAILS on chunky tyres with its own "
     "tyre tracks curving away across open ground, which is the actual argument, that the "
     "newcomers build on ground the incumbent cannot move onto, and it turns the rails into the "
     "incumbent's constraint rather than a shared destination. The revision also drops a "
     "decorative Mediterranean landscape that carried no meaning, and the rugged tyres remove a "
     "reading in which the newcomer looked like a toy. What survives from the first version is "
     "the load-bearing choice: BOTH CARTS CARRY THE SAME TEAL AI BLOCK, which is the 'AI is "
     "available to both sides' claim made visual, while the difference between them is a stack "
     "labelled PAST SUCCESS with a ball and chain attached, which is Wardley's climatic pattern "
     "drawn literally. Shipped as the article's first figure, above the Wardley-axis diagram: "
     "the picture carries the hook, the diagram carries the mechanism. Re-encoded from 2.26 MB "
     "PNG at 1915 wide to 63 KB webp at 1600. AND THE STRAPLINE BECOMES A PULL-QUOTE: 'Same AI. "
     "Different inertia.' states the thesis in four words, better than any sentence in the "
     "piece, so it now sits as a block quote between the lead and the diagram.",
     ),
    ('v0.4.7', '2026-09-21', 'git 7ec295ce',
     "THE SAAS ARTICLE'S FILE NAME NOW MATCHES ITS TITLE. The slug had stayed at "
     "what-saas-refused-to-build through three retitlings so that links already handed out "
     "kept working; the author has now asked for the name to follow the title, with no "
     "redirect from the old one. The article lives at "
     "/articles/saas-apocalypse-decided-by-inertia-not-by-ai.html, the old built page and "
     "its markdown twin are deleted rather than left as ghosts, the startups section's link "
     "follows, and the old address returns 404 on purpose.",
     ),
    ('v0.4.6', '2026-09-21', 'git c6819b40',
     "THE SAAS ARTICLE TAKES ITS FINAL TITLE, AND THE ARGUMENT IT IMPLIES. 'The SaaS apocalypse "
     "will be decided by inertia, not by AI', chosen by the author from the inertia set, and "
     "the reason it is the right one is a claim the piece had not yet made explicitly: AI IS A "
     "CONSTANT, AVAILABLE TO BOTH SIDES. The incumbents have the same models as the newcomers, "
     "plus more data, more engineers and more money, and if the technology were the deciding "
     "factor they would already have won; two and a half years in, they have not. So the "
     "variable is inertia, in the Wardley sense: where a company sits on the evolution axis "
     "and how much past success it has to protect, which is what decides which side of the "
     "Nokia path each SaaS provider ends up on. FOUR PASSAGES CHANGED TO CARRY THAT. The lead "
     "now states it outright as the reason for the title. The market section gains a paragraph "
     "observing that the February panic and the September recovery were both staring at the "
     "wrong variable: the panic said AI would hollow out SaaS, the recovery said it would not, "
     "and the same models were available to the largest incumbent and a two-person company on "
     "the day the plug-ins shipped. The incumbents section adds that they have the same AI and "
     "more data to point it at, and that the constraint is not the models. The closing "
     "separates the two: the strategy question is not about AI, which both sides have, but "
     "about inertia, which only one side has. Abstract and startups link follow the title. "
     "Slug unchanged.",
     ),
    ('v0.4.5', '2026-09-21', 'git 75773920',
     "OPTIONAL WAS THE WRONG WORD, AND THE AUTHOR SAID WHY. Nokia did not opt into either of "
     "its outcomes: the first time it had nothing to protect and moved, the second time it had "
     "fifteen years of success to protect and did not, and the mechanism is the climatic "
     "pattern Wardley names, success breeds inertia. A title built on choice misdescribed the "
     "argument, and so did the three passages in the body that leaned on it. The article is "
     "now 'The SaaS apocalypse belongs to whoever has the least inertia', which names the "
     "mechanism and makes the newcomers' opportunity immediate, since the incumbents have the "
     "most inertia and the newcomers have none. The lead, the abstract and the closing were "
     "rewritten around inertia rather than choice: the apocalypse is a forecast about inertia, "
     "not technology; it will happen to the companies with the most to protect and belongs to "
     "the ones with the least; the fatal version is not a decision anybody takes but the "
     "default behaviour of a business with success to protect; and the strategy question it "
     "poses is one the newcomers have already answered, because for them there was never "
     "anything in the way. The word does not survive anywhere in the piece, checked by the "
     "build. Slug unchanged; the startups section's link follows the title.",
     ),
    ('v0.4.4', '2026-09-21', 'git 647938a5',
     "THE STARTUP ARTICLE TAKES THE TITLE IT WAS GIVEN ON LINKEDIN. When the author republished "
     "it there, the title gained a prefix, 'For a startup, the most important question is "
     "whether they miss it', which says who the piece is for before it says what it argues, "
     "and the two versions should match. Sentence case here, to match every other title on "
     "the site. The slug is unchanged, the startups section's link text follows the new title, "
     "and nothing else about the article moved. The SaaS article's title is under discussion "
     "and stays as it is until the author picks one.",
     ),
    ('v0.4.3', '2026-09-21', 'git ca1e56f8',
     "THE SAAS ARTICLE GETS ITS REAL TITLE, AND EVERY ARTICLE GETS AN ABSTRACT. Three notes "
     "from the author on the two new articles. (1) THE TITLE NEEDED THE WORDS SAAS APOCALYPSE "
     "IN IT, with an ironic twist, because the key move in the argument is that it has not "
     "happened yet and remains a very strong possibility depending on how the incumbents "
     "behave. The article is now 'The SaaS apocalypse is optional', and the twist is carried by "
     "Nokia, twice: Nokia met the arrival of the mobile phone by becoming the mobile phone "
     "company and dominated for fifteen years; Nokia met the arrival of the iPhone and was gone "
     "from the category within seven. Same company, two responses. The apocalypse is optional "
     "in exactly that sense, a forecast about behaviour rather than technology, and every "
     "company gets to choose which Nokia it is. Added to the summary, the lead and a new "
     "closing paragraph; the slug is unchanged so the link already given out still works. (2) "
     "THE FIRST PARAGRAPH IS AN ABSTRACT, AND NOW SAYS SO. The author republished the startup "
     "article on LinkedIn with the summary prefixed 'Abstract:' and set in italics, which reads "
     "better than a bare lead, so the article template now does the same for every article: "
     "the summary renders as an italic paragraph with a bold Abstract label, and the markdown "
     "twin carries the same emphasis. (3) THE INFOGRAPHIC BECOMES THE HERO. The author made a "
     "cover image for the startup article (a hand lifting one app tile out of a grid, leaving "
     "an empty slot and a speech bubble asking for it back, over the three pillars) and asked "
     "for it on the page, compressed. It is now the first figure of 'The most important "
     "question is whether they miss it', re-encoded from 174 KB at 1733 wide to 94 KB at 1600 "
     "wide, above the flow diagram rather than instead of it: the picture carries the hook, "
     "the diagram carries the mechanism. The startups section's link to the SaaS article was "
     "updated to the new title.",
     ),
    ('v0.4.2', '2026-09-21', 'git ef990399',
     "THE SAAS APOCALYPSE, ARGUED WITH DATA RATHER THAN VIBES. A second article from the "
     "author's voice memos, 'What SaaS refused to build is exactly what the agents need', and "
     "the brief was to find evidence for the claims about quality, usability, satisfaction and "
     "value for money of medium to large SaaS. Every figure was fetched from its source rather "
     "than recalled, and each is linked. THE MARKET HAD THIS ARGUMENT ALREADY: in the first "
     "week of February 2026, after Anthropic shipped Claude Cowork plug-ins, roughly $285 "
     "billion left software stocks in about 48 hours, a Jefferies trader named it the "
     "SaaSpocalypse, Thomson Reuters fell 16% in a day, the S&P software index dropped 13% in "
     "five sessions, and Forrester titled a piece 'SaaS as we know it is dead'. Then it "
     "recovered, about 40% off the April low by September, on the narrative that systems of "
     "record and proprietary data are durable moats. The article's reading: that recovery "
     "concedes the point, because it says the DATABASE is the moat and stops defending the "
     "screen and the seat. J.P. Morgan's Mark Murphy is quoted approvingly: it is 'an illogical "
     "leap' to expect every company to write AND MAINTAIN a bespoke product, and the article "
     "agrees, because that gap is where the opportunity lives. USERS WERE NEVER HAPPY, "
     "MEASURED: Freshworks 2022, 8,698 respondents, 91% frustrated with workplace technology, "
     "57% of the unsatisfied say software makes them less productive, 68% of leaders name hard-"
     "to-use apps as the biggest adoption problem. Pendo 2019, 615 instrumented products, 80% "
     "of features rarely or never used, up to $29.5 billion spent building them; the older "
     "Standish 64% is cited WITH Mike Cohn's caveat that it came from four internal apps in a "
     "2002 keynote and should never have been generalised. Zylo 2025, $21M a year wasted per "
     "organisation on idle licences, up 14.2%, $4,830 SaaS spend per employee, 660 apps in a "
     "large enterprise, lines of business controlling 70% of spend. And the spreadsheet: "
     "Deloitte 73% prepare VAT returns in Excel, YouGov 78% of British companies say "
     "spreadsheets support key financial decisions, read as the clearest evidence that SaaS "
     "failed its users, since Excel is where they control the shape of the thing. WHY, IN "
     "WARDLEY'S WORDS: past success breeds inertia, quoted from the doctrine chapter ('the pre-"
     "existing installed base causes inertia to the change'), and the map claim stated so it "
     "can be argued, per wardley-maps.sgit.ai's thesis that maps are claims not pictures: the "
     "SaaS product has slid right into a substrate of database, message bus and state machine, "
     "on which higher-order things now get built. EVERYBODY WAS ALREADY VIBE CODING, SLOWLY: "
     "Karpathy's term from February 2025, Collins word of the year by November, described as "
     "people writing very good briefs, which product owners always did in an environment with "
     "a catastrophically slow loop. WHY INCUMBENTS CANNOT SIMPLY DO IT: non-functional "
     "requirements were never funded, with Stripe's Developer Coefficient (17.3 hours a week, "
     "42% of developer time, on maintenance and bad code, $85 billion a year) and DORA 2024 "
     "(19% elite, high cluster shrunk 31% to 22%, low cluster grown 17% to 25%, elite deploying "
     "182 times more often) as the evidence, and the line the author stands behind: show me a "
     "SaaS company and I will show you a company that is highly inefficient at developing "
     "software. THE IRONY IN ONE PARAGRAPH: portability, open schemas, a real API and data you "
     "can take out were starved for twenty years as churn risks, and are precisely what a "
     "customer's agent turns up needing. THE MISTAKE AND THE MONEY: confusing the brief with a "
     "product, because SaaS was bought for being MAINTAINED, and the person who vibe coded the "
     "replacement does not want to maintain it, so the future is village and town-planner "
     "companies that take the brief and keep it running, paid by consumption, plus the few "
     "incumbents who pivot to a platform for exactly that, with sgit and Fractal Semantic "
     "Graphs named as one shape it can take. Closes on more engineers not fewer, and on Brooks: "
     "the two-pizza limit existed because communication did not scale, and with humans and "
     "agents it now can. ONE DIAGRAM: the SaaS product sliding right on the evolution axis into "
     "a substrate, the explorer to villager to town planner handover on top, and the mistake "
     "drawn in red. Linked from the startups section as the description of the market a "
     "founder is building into.",
     ),
    ('v0.4.1', '2026-09-21', 'git 09c06a53',
     "THREE DISCLAIMERS STOOD BETWEEN A VISITOR AND THE MOST POWERFUL PAGE ON THIS SITE. The "
     "author's note: /demos/vaults/ is one of the strongest things here, and a first-time "
     "visitor met four boxes of process, policy and incident text before a single openable "
     "vault. Measured before the change, the table of 31 vaults began 919 px down at 1280 wide "
     "and 2,000 px down at 390. MEASURED AFTER: 492 px and 832 px. A cut of 427 px (46 percent) "
     "on desktop and 1,168 px (58 percent) on a phone, and five vault rows now sit above the "
     "900 px fold where previously not even the table header did. No horizontal overflow at "
     "either width, and the table's sort still asserted working rather than eyeballed. WHAT "
     "CHANGED ON THE PAGE: the lead cut from 86 words to 50 and rewritten to start with the "
     "instruction rather than the description ('Open any of these in your browser right now'); "
     "the fractal note turned from a box into a component, a new .vt-orient strip carrying the "
     "sentence and its two links in one flex row, 95 px down to 38 px at 1280 and 252 px to 127 "
     "px at 390, with hidden .hv-sep separators so the markdown twin still reads as prose; and "
     "one duplicated pointer to the publishing method deleted outright rather than moved. "
     "NOTHING WAS THROWN AWAY, which was the author's actual instruction: the three notes were "
     "interesting, they were just in the wrong place. They are now full entries on a new "
     "LESSONS LEARNED page at /lessons/, in the Evidence menu beside Case studies, built on the "
     "premise that a rule with no origin is only a preference and the first person who finds it "
     "inconvenient will drop it. Each entry states THE RULE and WHERE IT CAME FROM: classify a "
     "credential before it touches anything (from the submission that was a vault key described "
     "as a read key), read keys yes and vault keys never, audit every vault before its key "
     "appears anywhere, and write the method down rather than just the outcome. It links to the "
     "existing case studies rather than repeating them. AND A COUNT CORRECTED ACROSS THREE "
     "PAGES while checking the work: the ladder has NINE vaults on it, counted from the table "
     "rather than recalled, but the vaults page said eight and the fractal page's lead said "
     "'seven more' beside the origin vault, which is eight. Both drifted when the DSIT vault "
     "became a rung in v0.3.1 and the surrounding prose was not updated. The homepage band "
     "added in v0.3.8 already said nine, so the three pages now agree.",
     ),
    ('v0.4.0', '2026-09-21', 'git 5f8da5b0',
     "A STARTUPS SECTION, BECAUSE A LARGE PART OF WHY SGIT EXISTS IS TO LET OTHER PEOPLE BUILD "
     "ON IT. New top-level section at /startups/, in the Why menu beside Investors, and it opens "
     "by pointing at the operating model rather than the technology: the article shipped in "
     "v0.3.9 is its centrepiece, because a founder's journey starts with the model and only then "
     "needs the substrate. WHAT YOU GET ON DAY ONE, framed as the four costs a vault removes: a "
     "database to run (none, nothing runs between requests so nothing is billed between "
     "requests), hosting for your reader (none, the vault carries its own app and also runs from "
     "a plain bucket, so deployment is a copy), an account and an install (neither, a read key "
     "is the whole credential), and versioning bolted on later (already there, so you can take "
     "something away and put it back). Plus the audit story: the server cannot read it, by "
     "default rather than by tier. THE LOOP FROM FIRST VAULT TO FIRST CUSTOMER in five steps, "
     "each linked to the page that owns it, ending on the one the stack deliberately does not "
     "do: billing. With an honest caveat on step four, that rotating a key ends a trial but "
     "REVOCATION IS NOT RETROACTIVE, so anybody who cloned keeps what they cloned. WHAT YOU "
     "STILL HAVE TO BRING, which is the section that makes the rest credible: billing and "
     "payments (not here, not planned), identity and per-user accounts (a read key is a "
     "capability, not a login), server-side search and query (the server cannot read the "
     "content so it cannot index it), and the judgement about what to ship. AND THE ARGUMENTS "
     "THIS RESTS ON POINT OUTWARDS rather than being restated: open-source.sgit.ai and "
     "subscriptions.sgit.ai already own two of the three pillars, and they are linked as sibling "
     "cards. The section says outright that it is new, deliberately small, and will become its "
     "own site when it outgrows a page, the way nineteen others have. Registered in pages.json, "
     "NAV and LLMS_SECTIONS; the build's own guard caught the missing llms.txt section before "
     "the validator caught the sitemap, which is the guard working as designed.",
     ),
    ('v0.3.9', '2026-09-21', 'git d856aa88',
     "A NEW ARTICLE ON THE STARTUP OPERATING AND INVESTING MODEL, FROM THE AUTHOR'S OWN VOICE "
     "MEMO. 'The most important question is whether they miss it' states the model in three "
     "pillars. ONE, be profitable: a startup is simply a product somebody wants to buy at a "
     "price that is profitable to you, and if the PRODUCT is not profitable it does not scale "
     "whatever the company's accounts say. The skill that matters is SHIPPING, daily, and not a "
     "proof of concept or a demo or a video of a demo but a thing a stranger can use alone and "
     "get value from, tape and vibe coding entirely acceptable. Keep the running cost near "
     "nothing (serverless, as little live state as you can stand) which is why the file system "
     "is the database here, and the performance page is cited for what that costs. THEN THE "
     "PART ALMOST NOBODY DOES DELIBERATELY: give it away for a short window, then TAKE IT AWAY, "
     "and ask the only question that matters, DO THEY MISS IT. A shrug means no product yet, "
     "only something people accept when free, which is a much weaker signal than it feels from "
     "the inside. Coming back is the why now, and the moment the model turns on. Then two "
     "questions rather than one: is the price inside their value-added zone, AND is it a profit "
     "for you, with the honest caveat that it may only be profitable at scale and the gap should "
     "stay small enough to self fund. Revenue first; no revenue at all is not a marketing "
     "problem. TWO, investors should be calling you: the worst time to raise is before "
     "profitability, which is exactly when most companies do it. You bargain from weakness with "
     "a clock running, and the worst terms are not the money, they are the CONTROL handed to "
     "people whose job is to make money rather than to understand your business, whose opinion "
     "now carries weight and is structurally misaligned. Stated as one of the most common and "
     "most self-inflicted ways companies fail. The analogy is music: do not get signed before "
     "you have the album, write the songs, record them in a bedroom studio, play locally, get "
     "people buying, THEN take the deal as somebody who does not need one. Not 'never raise', "
     "raise from strength. THREE, open source everything, with five reasons that each stand "
     "alone: the technology is not the moat and believing it is shapes every decision badly; "
     "code written to be read by strangers has cleaner boundaries; a team that has never deleted "
     "a component will defend one too long; it is plainly better for the customer, who can read "
     "the thing rather than trust a promise; and it is a better FOUNDER EXIT, because building "
     "proprietary accumulates technology, much of it not even specific to the startup, that you "
     "cannot take with you. Links out to subscriptions.sgit.ai (a subscription is a discount for "
     "regular use, not rent on something ignored) and open-source.sgit.ai (open source is a "
     "strategy, not a charity) rather than restating arguments those siblings already own. ONE "
     "DIAGRAM, drawn for it: the loop from ship to give away to take away to the miss test, with "
     "a red return path for no and the paid path for yes, and the third pillar running "
     "underneath. AND A BUG FIXED ON THE WAY, present on EVERY article since v0.3.0: the article "
     "title template read '{title} (sgit.ai' with no closing bracket, so nine pages shipped a "
     "malformed title tag. It is em-dash-rewriter collateral: the paired-bracket rule opened a "
     "bracket in one place and closed it in another, the same failure class caught in table "
     "cells at the time but missed in the build script because the validator's prose check does "
     "not read .py. Two more orphans from the same pass fixed in comments, and a stale '#25 the "
     "newest' in the vaults docstring corrected to #31.",
     ),
    ('v0.3.8', '2026-09-21', 'git e4761f4d',
     "THE FLAGSHIP CONCEPT PAGE WAS NOT ON THE HOMEPAGE AT ALL. Fixing the routes into the "
     "performance page in v0.3.7 surfaced a larger omission: FRACTAL SEMANTIC GRAPHS, the idea "
     "this site's whole vault collection is evidence for, had no homepage presence whatsoever. "
     "It now has its own band, placed after 'What people actually ship' because that is the "
     "natural escalation: here are the things people ship, and here is what nine of them turn "
     "out to be when you look at them together. THE SECTION states the definition in one "
     "paragraph (every node opens into a graph with its OWN ontology, a regulation then its "
     "articles then the words they define, each world using vocabulary the one above never "
     "agreed to; the grammar is constant, the schema never is; for years we called it graphs of "
     "graphs of graphs) and then splits into two routes: the idea and the evidence (the jump "
     "test, a ladder of twelve rungs from the text of a law down to one compute instance, nine "
     "published vaults openable with a read key) and what it costs, measured (no live database, "
     "a 1,051-node graph opening in 94 KB and three requests, 11.2 MB of source down to a 4 KB "
     "ontology with nothing discarded). Counts were checked against the pages rather than "
     "recalled: nine rows in the open-them table, twelve linked rungs on the ladder. AND A "
     "LAYOUT BUG FIXED ON THE WAY: the homepage bands alternate background between band and "
     "band alt, and inserting a section would have broken the rhythm for everything below it, "
     "so the classes from proof through network were reassigned in order. That also corrected a "
     "pre-existing double-alt between network and the articles band.",
     ),
    ('v0.3.7', '2026-09-21', 'git 99f0cbb8',
     "THE BEST PAGE ON THE SITE WAS ALSO THE HARDEST TO FIND. Six releases of measurement went "
     "into /demos/fractal-graphs/performance.html and it was reachable from one nav entry under "
     "Vaults plus three content links, none of them on a landing page: the fractal page's "
     "closing section, docs/vault/reading-a-vault-file.html, and api/vault-objects.html. The "
     "author asked where the link actually was, which was the right question. NOW LINKED FROM "
     "FIVE MORE PLACES, all editorial rather than nav: the HOMEPAGE, in the agents card that "
     "already mentions sparse clones for fast cold starts, and again in that section's footer "
     "line; the DOCS INDEX, under Concepts beside the security model and credentials, where "
     "somebody looking for reference material will actually look; COMPARISONS, with a note "
     "saying outright that the performance comparison lives on its own page because it needs "
     "more measurement behind it than an entry allows, which is also the honest reason it is not "
     "an entry there; the VAULTS INDEX, in the note that already points at the fractal ladder; "
     "and the FRACTAL PAGE'S OWN LEAD, not just its closing section, since a reader who wants "
     "the engineering rather than the concept should not have to reach the bottom of a long page "
     "to find out it exists. ALSO ADDED TO THE EVIDENCE MENU beside Comparisons, in addition to "
     "its existing place under Vaults beside Fractal graphs. A page about how fast something is "
     "and what it costs belongs where people look for evidence, not only where its sibling "
     "happens to live. No content changed in this release, only the ways in.",
     ),
    ('v0.3.6', '2026-09-21', 'git f0a8d430',
     "THE API IS A CONVENIENCE OVER THE STORE, NOT A REQUIREMENT OF IT, AND THE PAGE NOW PROVES "
     "IT WITH A NUMBER. The author's point: if performance really matters, expose the objects "
     "directly through a CDN and skip the function entirely, which is safe precisely BECAUSE "
     "everything is encrypted. MEASURED, same client, same vault, same 106 objects, same "
     "decryption, only the path to the bytes changed: full clone through the serverless API "
     "65.4 s; full clone against PLAIN GETS WITH NO API IN THE PATH 2.63 s; straight off a local "
     "folder 2.05 s. TWENTY FIVE TIMES FASTER with nothing about the vault changed. Honest "
     "caveat on the page: the static host was localhost so the network was free and a real CDN "
     "adds edge latency, but the client and the work were identical in both rows, so the "
     "difference is the function in the middle. IT ALREADY SHIPS: sgit clone --transport static "
     "points at any GET host, sniffs which of two published layouts it uses on the first "
     "successful read, fans out EIGHT PARALLEL REQUESTS at a time, and records every URL it "
     "touches so a test can assert no request ever carried key material. WHY IT IS SAFE: every "
     "object is ciphertext under a key the server never had and every name is a hash of those "
     "bytes, so public GETs disclose object sizes and request timing, the same side channel the "
     "API already has and which the security model already names, and nothing else. AND THE "
     "DIRECT PATH IS ALREADY IN PRODUCTION FOR LARGE FILES: anything over 4 MB (the safe margin "
     "under the serverless base64 response limit of ~4.7 MB) is handed out as a presigned URL "
     "and fetched STRAIGHT FROM STORAGE, which is also the fallback when a batch 502s. So there "
     "are already two paths to every byte and the only reason the small case goes through a "
     "function is that nobody has needed it not to. FOUR ORDINARY STORAGE OPTIONS listed: a CDN "
     "in front of the bucket, where immutable hash-named objects are the ideal cache key; ranged "
     "and parallel GETs, which is how a 100 MB or 500 MB object should be read; lower-latency "
     "storage classes such as S3 Express One Zone, named WITHOUT a number because we have not "
     "benchmarked one; and another provider entirely, since what is served is opaque bytes at "
     "deterministic paths. FINDING THE OBJECT: usually you already know the id because it is "
     "pinned in the page, and when you do not the walk is ref to commit to tree to blob, a couple "
     "of requests rather than a clone. Counted against the static host, reading one named file "
     "out of a sparse clone cost TWO requests, one of which was the client re-probing the host "
     "layout, a 404 it could skip. This is the thing git cannot do, because git packs its "
     "objects and negotiates its transport, so one file usually means cloning the repository. "
     "AND ONE GAP STATED OUTRIGHT: there is NO HISTORY-DEPTH FLAG. --sparse skips content "
     "(256 KB, 7.3 s) and --bare skips the working copy, but a full clone still takes every "
     "version, 106 blobs where the current tree is 42 files, and a sparse clone still walks all "
     "7 commits and 28 trees. Depth control is the single change that would most improve clone "
     "time.",
     ),
    ('v0.3.5', '2026-09-21', 'git 11fafba1',
     "THE HEADLINE LATENCY WAS A COLD START PRESENTED AS AN ARCHITECTURE COST, AND THE BATCH "
     "OPTIMISATIONS WERE MISSING. Both flagged by the author, both wrong on the page, both now "
     "measured properly. THE FIXED COST IS NOT ABOUT BYTES: six runs each, best of run, a GET "
     "returning 340 bytes took 0.331 s and a GET returning 41 KB took 0.309 s. The same. At 2.3 "
     "KB it is 0.347 s. Only at 1.0 MB does transfer appear at all, and then it is 0.300 s of a "
     "0.647 s total. One request in the run returned in 4.8 s, which is what a cold start looks "
     "like. So the floor of about a third of a second is THE PER-INVOCATION COST OF RUNNING THIS "
     "API SERVERLESS, a deployment choice, not a property of reading encrypted data. SG/API also "
     "runs on EC2 with a warm process already listening, where that cost does not exist; not "
     "measured here, so the page puts no number on it and says so. The v0.3.4 table said 'cold "
     "network fetch 1,210 ms' as though the architecture cost that: it was the CLI figure, and it "
     "is now split into 347 ms of fixed overhead plus 300 ms of transfer, with the raw GET of the "
     "same object (0.65 s) shown beside the client one (1.21 s). BATCHING, WHICH IS THE ANSWER "
     "TO THE FIXED COST, measured on small objects so only the per-object term shows: 1 object "
     "0.590 s, 2 in one batch 0.394 s, 5 in 0.560 s, 10 in 0.872 s, 18 in 1.528 s. That is a "
     "straight line, TIME = 0.25 s FIXED + ABOUT 71 ms PER OBJECT + TRANSFER. Eighteen objects "
     "one at a time would be roughly 6.3 s; in one batch 1.53 s, FOUR TIMES FASTER, and the "
     "saving grows with the count. THE BATCH ENDPOINT DOES MORE THAN READS, which the page had "
     "not covered at all: up to 100 operations per request, MIXED READS AND WRITES, authorised "
     "per operation, with op types read, write, write-if-match and delete. write-if-match carries "
     "the SHA-256 of the content you believe is there and IF ANY MATCH FAILS THE WHOLE BATCH IS "
     "REJECTED, which is optimistic concurrency across a set of files in one round trip with no "
     "lock anywhere. Large blobs route through presigned URLs rather than the response body. AND "
     "THE NEXT OPTIMISATION IS NAMED RATHER THAN GLOSSED: 71 ms per object inside a batch is "
     "close to linear, the signature of objects being fetched from storage one after another in "
     "the handler; fetching them concurrently would bring a 20-object batch near the cost of a "
     "1-object batch. That, plus chunking by accumulated size rather than file count so the 502 "
     "stops happening, are the two changes that would move these numbers most, and both are in "
     "the API and the client rather than the design.",
     ),
    ('v0.3.4', '2026-09-21', 'git 2e7f594a',
     "THE BIGGEST PERFORMANCE FACTOR IS THE DESIGN, AND THE PAGE HAD BURIED IT. The author's "
     "point: performance here comes from architectural decisions and the design the site "
     "promotes, because you start by asking WHAT DATA DO I NEED FOR THE TASK AT HAND. The page "
     "now opens on that and gives it a section of its own. STANDING ON GIANTS, three layers that "
     "are already fast and are composed rather than replaced: the file system, a high-performance "
     "indexed store with the OS page cache in front; a content-addressed hash store on top of it, "
     "where a name is a hash so lookup is a path, dedup is free and a cache entry cannot be "
     "wrong; and the graph on top of that, which is the part that tells you WHICH BYTES to ask "
     "for. AND THE FAIR VERSION OF THE DATABASE COMPARISON: a database is fast largely because it "
     "keeps the working set in memory, which is the same insight, not cheating. The difference is "
     "who picks the working set. A buffer pool guesses it in advance from access patterns for "
     "every reader at once; we pick it per question at the moment the question is asked and throw "
     "it away after. MEASURED, BECAUSE THE HABIT ONLY MAKES SENSE IF LOADING IS CHEAP, on the "
     "1.0 MB file holding the whole DSIT graph: cold network fetch 1,210 ms; local disk read "
     "2.5 ms at 418 MB/s; AES-256-GCM DECRYPT 0.351 ms at 2,978 MB/s; JSON parse 3.2 ms. So "
     "decryption is 0.03 percent of the cold fetch and about a TENTH OF THE JSON PARSE, and on a "
     "59 KB shard it is 0.025 ms. Encryption is free at these sizes and the network is "
     "everything, which puts the question back on how many bytes you asked for. THE DESIGN RULE "
     "AS A BUDGET: under 100 KB is an answer, around 1 MB is a whole world, 10 MB and up means "
     "you are loading a store rather than an answer, and gigabytes is a design error that no "
     "tuning will fix. SAVE IS WHERE THE LOOP COMPOUNDS, the LETS step people skip: saving is not "
     "filing the answer away, it is leaving an artefact cut to the shape of the NEXT question. "
     "The Article 9 slice exists because somebody asked about Article 9 once, so asking again "
     "costs 29 KB instead of a megabyte. Build time replaces query time one question at a time, "
     "paid by whoever asked first, with nothing decided in advance. RESTRUCTURING INTO CONTEXT IS "
     "COMPRESSION, the fractal property argued as volume rather than meaning, with the measured "
     "ladder in the Regulation Graph vault: raw Formex source 11,216,043 bytes; the graph, 1,523 "
     "nodes and 1,944 edges, 1,073,915 bytes, 10x smaller; one article's pre-cut slice 29,638, "
     "36x smaller; the ontology 4,217, another 7x. Top to bottom 11.2 MB down to 4.2 KB, a factor "
     "of about 2,660, WITH NOTHING THROWN AWAY, because every level keeps the edge down to the "
     "one below. Query the small thing, follow the link only when the answer needs it. WHICH IS "
     "WHY IT SCALES IN THE DIRECTION THAT BREAKS SCHEMA-FIRST SYSTEMS: there, more data means a "
     "bigger index and a bigger machine; here the store can be terabytes while what a question "
     "loads stays in megabytes, because the graph is what tells you which bytes matter. Adding a "
     "terabyte adds nodes you did not load. ALSO: a real SQL engine comes along wherever the "
     "slice lands, and the managed services already work this way, loading a dataset into an "
     "in-memory SQLite or MySQL when an instance wakes, so we do explicitly what they do "
     "implicitly, chosen per question rather than per instance lifecycle. AND A CORRECTION FOUND "
     "BY MEASURING: this page said 617 nodes and 694 edges, quoting the vault's page. The file as "
     "cloned today has 1,051 NODES AND 1,289 EDGES across five worlds, because the vault released "
     "0.2.1 on 21 September and the graph grew. Every count on the page is now counted rather "
     "than quoted, and the drift is only visible because the vault keeps its versions rather than "
     "overwriting them. The vault's own page still carries the figure it was published with.",
     ),
    ('v0.3.3', '2026-09-21', 'git 4b468e61',
     "THE PERFORMANCE PAGE WAS MISSING THE THREE THINGS THAT MAKE IT FAST. The author read "
     "v0.3.2 and named them: reading an encrypted file takes a couple of requests and the site "
     "should have the guidance; the append mode has its own performance implications; and the "
     "immutability of the data files is what allows client-side caching of ENCRYPTED data, which "
     "is what the website and the vault web app already do. Plus a live example to point at, "
     "sgraph.ai/en-gb/library/, a high-performance site running entirely from encrypted data. "
     "FOUR NEW SECTIONS, all measured or read from published source on 21 September 2026. (1) "
     "ONE REQUEST: file ids are derived by HMAC-SHA256 over the read key under named domains "
     "(sg-vault-v1:file-id:ref, :branch-ref, :branch-index), identical constants in the CLI and "
     "the browser client, so a holder of the read key COMPUTES the address of the ref, the index "
     "and the settings before issuing any request. No discovery round trip. Reading is then a "
     "plain CORS GET with no auth header: measured 2,364 bytes of ciphertext in 0.86 s and "
     "59,324 in 0.63 s, best of five 0.33 s. One POST to /api/vault/batch/ returned FIVE objects "
     "in 0.87 s and TWENTY objects, 3.76 MB, in 2.79 s, all in a single round trip; 42 objects "
     "returned 502, the server's response-size limit, which the CLI already handles by splitting "
     "the chunk. Encryption costs a FLAT 28 BYTES per object (12-byte nonce, 16-byte tag), "
     "confirmed on both measurements, not a percentage. And two ways to address a file, pin the "
     "content-addressed id for one GET forever, or resolve HEAD to tree to blob for always "
     "current, which is a design choice rather than a default. (2) THE 65 SECOND CLONE IS "
     "CORRECTED, because the new numbers disprove the explanation v0.3.2 gave. The clone log "
     "shows 7 commits, 28 trees and 106 BLOBS, not 42 files, because a full clone takes the "
     "history; then one batch of 50 hit the response-size limit and degraded to 50 individual "
     "fetches, which is most of the 65 seconds. The transport moved 3.76 MB in 2.79 s when asked "
     "in one request, so this is a client-side chunking bug (chunk by accumulated size, not file "
     "count) and not a property of the architecture. Said on the page in those words. (3) APPEND "
     "MODE: lanes live at bare/append/{token}/pending/, OUTSIDE the commit tree, so appends never "
     "touch a branch and never conflict with a push. A write is one account-less POST with the "
     "token in the body: no session, no read-modify-write, no commit, no tree rebuild, no lock. "
     "The response is deliberately blind (ok true, no id, no count), so the server does no "
     "extra work and leaks nothing by size or timing. Writes never contend with reads because a "
     "reader walking the graph never looks at a lane. Several anchors means several lanes, so one "
     "flooding sender cannot bury another. (4) CACHING ENCRYPTED DATA, the easy case: the cached "
     "bytes are CIPHERTEXT, so a cache is not a trust boundary and the browser cache, IndexedDB, "
     "a CDN edge and a corporate proxy can all hold vault objects without widening exposure; and "
     "an obj-cas-imm- id is SHA-256 OVER THE CIPHERTEXT, so an entry under that name can never be "
     "stale or wrong and no invalidation logic exists anywhere. Mutable refs are the exception "
     "and the failure is silent. (5) LIVE EVIDENCE: the sgraph.ai library is a 270 KB static "
     "shell (28 KB HTML plus 242 KB of CSS and components, measured) whose every article, nav "
     "entry and blog post comes out of two encrypted vaults, decrypted in the tab, with the "
     "server unable to read a word of what it serves. Its blog entries pin their content-"
     "addressed object ids in the page for a single GET, while its navigation is deliberately NOT "
     "pinned and resolves HEAD to tree to blob at runtime so it stays current. Its cache script "
     "keeps immutable objects in IndexedDB for later page loads with no network at all, collapses "
     "concurrent callers into one request, and labels every response idb-hit, inflight or "
     "network. AND A GAP FOUND WHILE MEASURING, reported rather than smoothed over: our own "
     "caching contract page says immutable objects are served Cache-Control public, max-age="
     "31536000, immutable, and refs no-store. On 21 September 2026 the read endpoint returned NO "
     "Cache-Control header at all for an immutable object and the CDN reported a miss. The "
     "mechanism is unaffected because the client cache keys on the id rather than trusting a "
     "header, which is the more robust design, but the documented header is not live on that "
     "path today and the page now says so.",
     ),
    ('v0.3.2', '2026-09-21', 'git 5cdd3071',
     "A READER ASKED FOR THE PERFORMANCE NUMBERS, SO THEY WERE MEASURED RATHER THAN ESTIMATED. "
     "The question, after the fractal graphs page went out: what is the performance of this "
     "against ordinary graph engineering? /demos/fractal-graphs/performance.html answers it, and "
     "every figure on it was taken on 21 September 2026 from an ordinary cloud container against "
     "two live published vaults, using the read keys printed on their own pages. FROM THE "
     "COMMAND LINE, against the 42-file 3.2 MB DSIT vault whose graph is 617 nodes and 694 "
     "edges: full clone 65.4 s; sparse clone, meaning every path, size and hash with no content, "
     "7.3 s and 256 KB; one 59 KB file that answers one question 0.49 s; a second file 0.87 s; "
     "the entire graph as one 1.0 MB file 1.21 s; an already-fetched file 0.18 s with no network "
     "at all. So cloning everything costs 65 seconds and answering a question costs 7.8 seconds "
     "and 315 KB. The same sparse call against the 207-file 14.9 MB Regulation Graph took 7.06 s "
     "and 360 KB, because the index does not grow with the content. IN THE BROWSER, by "
     "instrumenting the page and recording every response, so these are exact byte counts: the "
     "landing view of that 617-node graph is 94 KB in 3 requests, guidance 295 KB in 4, the "
     "graph view 1.12 MB in 5, data and queries 1.19 MB in 6, against a 3.2 MB vault that no "
     "view ever loads whole. The ontology is 2 KB, 4,217 bytes in the Regulation Graph, which is "
     "the entire price of arriving in another world and learning its rules, and that is what "
     "makes the jump affordable rather than theoretical. The 859 KB query engine loads only if "
     "you query. THE ARCHITECTURE IS NAMED: no live database, files in object storage read "
     "directly, and LETS, meaning Load, Extract, Transform, Save, where Save writes new "
     "immutable files instead of overwriting, which is why every cache in the path is correct "
     "forever and why there is no server. THE COST MODEL IS THE OTHER HALF the author asked for: "
     "zero instance hours, zero replicas, zero index memory, no separate backup line because "
     "every version is already kept, no staging copy because a clone is a clone; storage and "
     "egress only; query compute paid by the reader's own device. The whole published estate is "
     "2,662 files and 295 MB. Cost scales with what is read, not with what exists and not with "
     "time. RUN EVERYWHERE, on one read key: a browser tab, a terminal, a serverless function, a "
     "CI container, a static host with no backend, plus fractal deployment, where the deployment "
     "unit is a set of files and a key, so putting a graph inside a regulated environment or an "
     "air-gapped machine is a copy rather than a project, and two organisations can join their "
     "graphs by exchanging an edge and a read key without either adopting the other's schema. "
     "AND SIX PLACES IT IS SLOWER, stated plainly, including the one a graph database wins "
     "outright: six hops across a hundred million edges in one schema. The commands to repeat "
     "every measurement are on the page.",
     ),
    ('v0.3.1', '2026-09-20', 'git 85f20a76',
     "VAULT #31, AND IT ARRIVED AT THE SAME CONCLUSION THIS SITE SPENT A WEEK REACHING. An "
     "independent reference edition of the UK DSIT AI Risk Management Toolkit, submitted under "
     "the sgit_public_read_ prefix, which is the form the credentials page now asks for and the "
     "first submission to use it. The vault models the guidance, the official workbook, the risk "
     "method and the cited frameworks as FOUR SEPARATE WORLDS with named bridges, 617 nodes and "
     "694 edges, and states as the first of its four declared limits: 'containment alone is not "
     "fractality. Cross-world edges make the semantic transitions inspectable.' That is the same "
     "correction the author made to the fractal graphs page on 19 September, reached by a "
     "different author in a different vocabulary, so the page now cites it and the ladder gains "
     "the rung. THE HONESTY IS THE OTHER REASON IT IS HERE: every edge is labelled curated or "
     "lexical, with the limit 'lexical mentions are not validated meaning' written down; five "
     "source snapshots are retained with their URLs, retrieval dates and SHA-256; two of its "
     "eight checks exist only to prove the official XLSX and ODS bytes were not touched; and it "
     "publishes six gaps about itself, including a correction of its own earlier count (208 "
     "formula cells, not 227), a version contradiction in the source preserved rather than "
     "resolved (the filename says v1.1, the Welcome sheet says v1.0), two broken defined names "
     "left broken because repairing somebody else's document is an edit, and the refusal to read "
     "starter rows as evidence of adoption. AUDIT: no credentials, no personal data, two "
     "published DSIT institutional contacts, OGL v3.0 acknowledged, official status disclaimed on "
     "the front page and in NOTICE.md, and an all-zeros negative control that produced no clone "
     "where the real key produced 42 files.",
     ),
    ('v0.3.0', '2026-09-20', 'git 8b46d285',
     "TWO SITE-WIDE NORMALISATIONS THE AUTHOR ASKED FOR, AND A GUARD FOR EACH. (1) THE EM-DASH IS "
     "GONE FROM PROSE: 3,200 of them, because a good many readers now find the character "
     "off-putting. Not a find-and-replace. A rewriter classified every occurrence by context and "
     "chose the punctuation the sentence actually wanted: brackets or commas around a "
     "parenthetical pair, a colon before an explanation, a full stop with the next word "
     "capitalised where the tail was an independent clause, a comma otherwise. Code was left "
     "alone by construction, and the same exclusions were written into the validator so the two "
     "agree: pre, code, svg, fenced and inline markdown code, inline script, the assets folder, "
     "the skills folder (upstream artifacts shipped verbatim) and team/board (rendered from a "
     "vault this site does not author). FOUR BUGS FOUND BY CHECKING RATHER THAN ASSUMING: a "
     "whitespace tidy reflowed the ASCII diagrams inside pre until it was made mask-aware; the "
     "pair rule bridged adjacent table cells and left seven brackets opening in one cell and "
     "closing in another, each found by a balance check and fixed by hand; the try page's "
     "JavaScript compared against placeholder text the markup no longer had, which would have "
     "broken its terminal; and a colon in front of a conjunction reads badly, so 35 became "
     "commas. (2) THE LEGACY KEY PREFIX IS GONE: 102 published read keys under sgit_rk1_ and 24 "
     "bare ones now carry sgit_public_read_, which declares the intent. The bare ones were bare "
     "for a reason worth recording: the prefixed form used to FAIL on the CLI, which is an open "
     "brief to the CLI team. Re-running the comparison suite today closed it, all six checks "
     "pass on sgit-ai v0.16.2 including prefixed clone succeeded. Only real keys were rewritten, "
     "so documentation that names the legacy prefix still names it.",
     ),
    ('v0.2.99', '2026-09-20', 'git 8aadadf9',
     "THE NEWEST FOUR VAULTS WERE AT THE BOTTOM OF THE TABLE, AND THE MACHINE LIST HAD THEM AT THE "
     "TOP. The author asked for the order on the published-vaults page to be fixed. Three bugs "
     "behind one symptom. (1) vaults_table() read vaults.json directly, in FILE order, while its "
     "own docstring claimed 'the rows ship newest-first in the HTML'. Vaults 27 to 30 had been "
     "appended to the end of the file rather than inserted at the top, so they rendered last: the "
     "page opened at #26 and ended at #30. (2) The llms.txt generator sorted by ordinal and was "
     "therefore correct, which means the human table and the machine list had DISAGREED for four "
     "releases, on a page whose own footnote says the two are generated from the same file and "
     "cannot drift. (3) Found on the way: the routine that lifts each read key out of its vault "
     "page knew sgit_rk1_ and sgit_private_read_ but not sgit_public_read_, so the two keys "
     "relabelled in v0.2.98 were being published in llms.txt stripped of the declaration they had "
     "just been given. THE FIX IS ONE ORDER FOR THE WHOLE SITE: _vaults() now sorts newest-first "
     "and everything reads it, and it asserts what makes that trustworthy, ordinals unique, "
     "running 1..N with no gaps, and in the same order as the published dates. Both assertions "
     "were tested by breaking the data on purpose: a duplicate ordinal and a date that contradicts "
     "its number each fail the build by name. The homepage is untouched because its hero row and "
     "its job bands use a hand-curated order, which is why this went unnoticed there.",),
    ('v0.2.98', '2026-09-20', 'git 245934e7',
     "WE PUBLISHED READ KEYS UNDER A PREFIX THAT DECLARES THEM SECRET, AND AN AGENT CORRECTLY "
     "REFUSED TO OPEN THEM. The author sent a screenshot: another agent, asked to inspect the two "
     "AIUC-1 vaults, declined because their credentials were labelled private read, and said it "
     "would not work around the restriction. It was right and our label was wrong. The CLI defines "
     "three current prefixes and says which is which in its own source: the private vault one is a "
     "WRITE credential, the private read one is read-only and KEPT SECRET, and the public read one "
     "is read-only and DELIBERATELY PUBLISHED. Seven published credentials across the two AIUC-1 "
     "pages carried the middle one. Relabelled to the public form, with a dated line on each page "
     "saying what changed and that the bytes, the access and the vault are identical. VERIFIED "
     "BEFORE RELABELLING, on sgit-ai v0.16.2: bare, legacy rk1, private read and public read all "
     "clone the same 699-file vault, an all-zeros key under the same prefix produces nothing, and "
     "the SG/Vault web loader strips all five prefixes in its own format module. THE STRUCTURAL "
     "FIX MATTERS MORE THAN THE SEVEN STRINGS. The validator now bans both current private "
     "prefixes in tracked files, using the same trailing-character rule the write-key tripwire "
     "already used, so documentation can still print a prefix as a NAME while a real key fails the "
     "build. check_credential.py learned the public prefix (publishable, and the form to use) and "
     "now REFUSES the private read one: read-only, leaks no capability, and still not publishable, "
     "because the label says the opposite of what publishing it does. NEW PAGE /docs/credentials/ "
     "explains the whole thing for the first time: two capabilities with a one-way derivation "
     "between them drawn as a diagram, the five prefixes in a table with publish-or-not per row, "
     "classification by declaration and never by shape (the CLI's own lesson: guessing from shape "
     "is what once misrouted a 64-hex passphrase to a read-only clone), why the word matters when "
     "the bytes do not, and what a read key can and cannot do including that revocation is never "
     "retroactive. Linked from the docs index, the nav, the gallery intake note and the publishing "
     "method. NOT DONE, AND A DECISION FOR THE AUTHOR: 99 published keys across 27 pages [CORRECTED IN v0.6.9: 102 keys in 27 tracked files, 93 in 26 content pages and 9 in one build script, recounted from git; this count was three short and called a script a page] still "
     "carry the legacy rk1 prefix, which is neutral rather than wrong.",),
    ('v0.2.97', '2026-09-20', 'git d460cd58',
     "THE ARTICLE THAT INTRODUCES THE TERM, WITH THE PICTURES LINKEDIN CANNOT CARRY. The author had "
     "another agent write 'Fractal Semantic Graphs: everything connects to everything, and nobody "
     "has to share a schema' from the sgit.ai page, for LinkedIn, and asked whether this site had "
     "a place to publish it with the missing images and infographics. It does: /articles/. The "
     "text is published as written, in the author's voice, with one italic line added at the top "
     "naming it the canonical copy and pointing at the page and the VoiceDebrief vault, and one "
     "at the bottom on reuse. Nine figures placed where the prose earns them: the three inline "
     "SVG diagrams rendered to images at two times scale (the zoom, the jump, the ladder), the "
     "GDPR atlas at altitude zero with its own 'you are at the top of the fractal' panel, the "
     "Regulation Graph landing view, the AIUC-1 explorer with two vaults on one canvas, the "
     "ThreatModCon zoom ladder, the role risk map, and Article 45's timeline of rulings over an "
     "unchanged article, which the article calls its favourite picture in the set. The bare URLs "
     "in 'Open them' became links, with the VoiceDebrief vault added to the list. No em-dashes; "
     "the article had none and none were introduced.",),
    ('v0.2.96', '2026-09-20', 'git 88f73d67',
     "THE VAULT NAMED AFTER THE CONCEPT WAS NOT ON THE CONCEPT'S PAGE. Asked whether the GitHub "
     "repository VoiceDebrief/VoiceDebrief__Fractal-Semantic-Graphs was in the collection, the "
     "answer was yes: it is the plaintext mirror of vault k6xy9z4d, row 11, published on 22 "
     "August, and cloning both showed the repository's 90 content files matching the vault's "
     "HEAD file for file, both last moved on 10 August. What was missing was the reverse link. "
     "The Fractal Semantic Graphs page walked seven graph vaults and never cited the one that "
     "carries the name, the fifteen-principle register (P4 everything is a node, P6 fractal "
     "descent, P7 the junction rule, P11 altitude, P15 structure points down and meaning "
     "radiates out), the leading brief of 6 August whose sentence 'each level is the same "
     "operation applied to a larger span, which is what makes the structure fractal rather "
     "than merely nested' says in one line what this page took five revisions to reach, and the "
     "notation spec's two laws (read aloud as a sentence and decompose into triples; link to "
     "the span you compress, because claims from memory are not allowed). A new section, "
     "'where the idea was worked first', quotes all of that with a principle-to-page table, "
     "credits the Article 9(2) example in the zoom diagram to the vault that works that "
     "provision to exhaustion, and adds the vault to the open-them table. THE VAULT'S OWN PAGE "
     "gains a mirror section: the vault practises its own guide, plaintext tree and encrypted "
     "store side by side on GitHub, three commits all on 10 August; the consequences stated "
     "are that the content is public twice over (the read key adds history and the app, not "
     "access), that the repository is a snapshot which will drift if the vault moves, and that "
     "the encrypted store is safe in the open for the reason the guide gives. The page's line "
     "saying this site 'is built' the same way now says 'was built until v0.2.84'. Counts "
     "updated: the concept card and gallery note say eight graph vaults; the graphs.sgit.ai "
     "brief asks that site to cite the principles register directly.",),
    ('v0.2.95', '2026-09-19', 'git 2724d757',
     "THE FOLDER TREE WAS THE WRONG CONTRAST. The fractal graphs page said a hierarchy 'gives you "
     "more detail but no new meaning, because the only verb is contains'. The author's objection, "
     "in four parts: a single layer's ontology can have a very large number of verbs, each carrying "
     "meaning; as long as the links make sense inside that ontology, knowledge is gained one link "
     "at a time and it is massive; what the fractal architecture adds is the ability, on one of "
     "those links, to jump into another universe with its own rules and definitions; and what "
     "makes that fractal is that every such self-contained world is built from the same blocks, "
     "nodes, edges, ontologies, taxonomies, triplets. The test section is rewritten on his worked "
     "example, given as a voice memo: a risk register (its own rich verbs: gives_rise_to, "
     "owned_by, reports_to, mitigates, backs) whose incident fact jumps into security operations "
     "(alerts, signals, ATT&CK, attack trees), whose suspicious DNS entry jumps into the DNS "
     "estate (zones, records, every logged request; possibly millions of nodes served through an "
     "abstraction layer over SQL, a graph database or GraphQL), whose one record jumps into a "
     "packet capture. Then the two consequences he drew: every connection followed should teach "
     "you something, even 'nothing to see here', with the observer or the query deciding whether "
     "value was added; and more granularity means a better representation of reality, chosen per "
     "context, with custom views on top being what makes it scale. Plus the upward direction "
     "(all of it one node in a bigger graph, graphs composed from graphs treated as a graph all "
     "the way up, the schema itself fractal in the Mandelbrot sense). A THIRD DIAGRAM draws the "
     "jump: four worlds in a row, each a small graph in its own vocabulary, joined by a jump link "
     "on one node each, a bracket above for the bigger graph. Its texts are measured against the "
     "viewBox like the other two. The graphs.sgit.ai brief's corresponding bullet and its "
     "diagram count are updated to match.",),
    ('v0.2.94', '2026-09-19', 'git f3476af0',
     "A BRIEF FOR GRAPHS.SGIT.AI, IN PLACE OF A FOOTNOTE. The fractal graphs page carried a small "
     "'one wording to pass upstream' paragraph about the boundaries page on graphs.sgit.ai having "
     "the fractal test backwards. The author asked for it to go, and for a proper brief instead, "
     "since the sgit.ai page is now the fullest worked application of that site's two theses. The "
     "paragraph is gone; /docs/briefs/graphs-sgit-ai-fractal-semantic-graphs/ is the brief. It "
     "quotes the boundaries page's four-row table and shows that the page contradicts itself: "
     "the Recursion row says 'identical rules, no new format, no special case' while the author's "
     "own quote two paragraphs below says an article may be 'so meaty that it requires its own "
     "ontology and taxonomy, and that's the power of the fractal element'. Replacement text for "
     "the table, the test and llms.txt's sentence nine separates grammar (invariant) from "
     "ontology (free). Then: adopt the name and its lineage (already item 1 of their review "
     "r001, state 'commented'); lift the precise form of meaning-through-connectivity for the "
     "fractal case, 'the deeper you go the more you learn', and 'nobody is forced to conform'; "
     "six places to link the page; the two SVG diagrams and the Standards Atlas screenshots to "
     "reuse; the four graph vaults their evidence estate does not yet analyse (Standards Atlas "
     "GDPR, AIUC-1 conformance, Licence to Operate, ThreatModCon) and the AIUC-1 crosswalk join "
     "as a second cross-vault finding; three small corrections found on the way (5 versus 7 "
     "stakeholder altitudes in their llms.txt, sentinel.sgit.ai still named where sg-sentinel is "
     "the host, the vault count); a section on what not to change; and the prompt to paste, "
     "verbatim, at the end. No em-dashes in the brief.",),
    ('v0.2.93', '2026-09-19', 'git 96afb763',
     "The fractal graphs page description, which the markdown twin prints as its blockquote, "
     "had one em-dash left; a colon now. The ', sgit.ai' suffix on every page title is a "
     "site-wide convention and stays until the site-wide pass is decided.",),
    ('v0.2.92', '2026-09-19', 'git 44f819a2',
     "NO EM-DASHES ON THE FRACTAL GRAPHS PAGE. The author asked for the dash to go, since a good "
     "many readers now find it off-putting. Eighty-two of them on forty-six lines, each rewritten "
     "by hand rather than by substitution: a parenthetical pair becomes commas or brackets, a "
     "dash before an explanation becomes a colon, a dash before a new thought becomes a full "
     "stop, the section headings go 'Altitude 0: the law', the diagram headers take a colon, and "
     "the ladder's header takes a middle dot. The one table cell that was a bare dash now says "
     "'not measured'. Site-wide there are about 3,300 more across 212 source files; that pass is "
     "a separate decision and has not been made here.",),
    ('v0.2.91', '2026-09-19', 'git 318e41d6',
     "'THE DEEPER YOU GO THE LESS YOU LEARN' WAS WRONG, OR AT LEAST WRONG ENOUGH TO CONFUSE. The "
     "author read the folder-tree sentence in the fractal graphs test and asked whether it should "
     "not be the MORE you learn, since depth brings connectivity. It should, for a fractal graph, "
     "and the sentence was trying to say something narrower about hierarchies: depth adds detail "
     "but no new meaning when the only verb is 'contains'. Rewritten to say exactly that for the "
     "hierarchy, and then to state the fractal case outright: the deeper you go the more you "
     "learn, because every altitude brings its own vocabulary of relationships and connectivity "
     "compounds instead of nesting. A new paragraph puts the cross-domain behaviour precisely, in "
     "the author's words. It means one thing: the core meaning of a node is supplied by the "
     "ontology at the altitude where it sits, and that ontology is free to change between "
     "altitudes (Article 9 is a binding provision, a container of paragraphs, and a source of "
     "definitions, depending on the level you read it from), and closes with nature as the "
     "example nobody finds strange: universe, galaxy, star system, planet, ecosystem, organism, "
     "cell, molecule, atom, particle, the vocabulary changing completely at every altitude while "
     "each level stays connected to its neighbours. No schema describes a galaxy and a cell; one "
     "grammar describes both.",),
    ('v0.2.90', '2026-09-19', 'git 05c3286d',
     "Captions on the zoom diagram shortened: panels two and three overran their columns after "
     "the v0.2.89 rewording, and the footer line was a pixel from the edge. Checked by rendering "
     "the SVG at two widths, not by eye on the source.",),
    ('v0.2.89', '2026-09-19', 'git e733f91a',
     "THE DEFINITION HAD THE WORD BACKWARDS. v0.2.87 defined a fractal semantic graph as one where "
     "'every node is itself a semantic graph built by the same rules', and stated the test as "
     "'if zooming into a node needs a new format or a special case, the system is hierarchical'. "
     "The author's reading of the page caught it: same types, same verbs, same rules at every "
     "level IS a hierarchy, a folder tree, one schema all the way down. It becomes fractal at "
     "the moment zooming in lands you somewhere different: a node whose inside has its own node "
     "types, verbs and taxonomy (a new format, a special case) and that new world is still "
     "joined by an edge to the one above. Graphs of graphs, ontologies of ontologies; the plural "
     "is the point. What stays constant is the GRAMMAR (edges are verbs with inverses, meaning in "
     "connectivity, provenance kept), never the schema, and that is exactly what lets everything "
     "connect to everything without anyone being forced to conform: an organisation, a division, "
     "a person, a regulator each define their own world and connect by declared edges, not a "
     "merged schema; granularity is a per-situation decision, so a paragraph can be a mini-world "
     "with more definition than the document around it. REWRITTEN ACCORDINGLY: the lead, the "
     "'what it is' paragraph, the three-panel diagram's captions (panel 2 is now labelled a legal "
     "ontology, panel 3 a lexical one, and the footer reads 'the grammar never changes; the "
     "ontology does'), the test paragraph, a new bullet under 'why connect everything' on every "
     "unit keeping its own world, the four-word table, the how-far lead, the ladder intro "
     "('eleven altitudes, eleven ontologies, one grammar'), and the rule section's heading and "
     "consequence paragraph. ONE WORDING TO PASS UPSTREAM: graphs.sgit.ai's boundaries page "
     "states the test with the word 'format'; read as 'stops being a semantic graph' it agrees "
     "with this page, read as 'schema' it says the opposite, and this page read it the wrong way "
     "first. It should say 'grammar'. Recorded on the page with the date rather than silently "
     "corrected.",),
    ('v0.2.88', '2026-09-19', 'git e8edf935',
     "THE LADDER'S RIGHT COLUMN LOOKED LIKE LINKS AND WAS NOT. Second read of the fractal graphs "
     "page: the 'why this page exists' note (the LinkedIn back-story) is gone, the page stands "
     "without it; and the ladder diagram's right-hand column was long blue monospace text that "
     "read as a row of links nobody could click. It is now two lines per rung: the vault's name "
     "as a real link (an SVG anchor, underlined, taking you to that vault's page and its read "
     "key, the Acceptance rung points at the seven-views page, the Agent rung at abp.sgit.ai) "
     "and the detail beneath it in plain grey. Twelve links, each tested by clicking it in a "
     "browser and checking the URL it landed on. The rule it re-learns: if it is blue, it must "
     "be clickable; if it is not clickable, it must not be blue.",),
    ('v0.2.87', '2026-09-19', 'git bcabde64',
     "THE FRACTAL GRAPHS PAGE NOW LEADS WITH THE DEFINITION. The author's first read of v0.2.85 "
     "made three points: the title should be Fractal Semantic Graphs, since 'how far down does "
     "the graph go' is a section; the page must start by defining and visualising the term and "
     "the power of connecting everything with everything, down to the smallest node that makes "
     "sense for the use case (in most of ours a word, a number or a symbol); and the first "
     "screens must work for a visitor who knows graphs, semantic graphs and ontologies but has "
     "never met the FSG idea, which the author used to call 'graphs of graphs of graphs' and "
     "'ontologies of ontologies of ontologies'. Done in that order: a lead that defines the term "
     "in three sentences; a section on what it is, with a three-panel inline SVG that zooms from "
     "a four-node semantic graph into the Law node (articles, paragraphs, an amendment as an "
     "edge) and then into one paragraph (the terms it uses, each an edge from the article that "
     "defines them), with the rules unchanged at every zoom; the hierarchical-versus-fractal test "
     "borrowed from graphs.sgit.ai; a section on why connect everything with everything, every "
     "file format is already a graph, so questions cross formats without a join table, "
     "corrections propagate instead of being republished, the smallest node is whatever the "
     "question needs, and provenance comes free when the leaf is a word tied to a byte range and "
     "a hash, with the verb rule as the discipline that keeps it from being noise; and a "
     "four-word table (graph, semantic graph, ontology, fractal semantic graph) saying what each "
     "adds and where it stops. The former opening became the 'How far down does the graph go?' "
     "section, unchanged below its heading. Title, description, nav card, gallery note and the "
     "llms.txt START HERE line renamed to match.",),
    ('v0.2.86', '2026-09-19', 'git c80e73cc',
     "THE HISTORY PURGE, DOCUMENTED AS THE SCENARIO IT WAS. Asked to confirm the force push had "
     "really happened (it had: '+ e70d582d...b5ae665d dev -> dev (forced update)') and to write "
     "down the exact situation (files we no longer want, in every commit, on a repository with "
     "more than one branch) a new case study draws it out in ASCII: the working tree with .git "
     "and .sg_vault side by side, the remote with dev and a forgotten August branch, the 15,933 "
     "files and the 276 MB to 21 MB pack, why git rm leaves every earlier snapshot intact, what "
     "filter-repo does to every commit id (not one of 112 survived), why the push is refused as "
     "non-fast-forward and how --force-with-lease pinned to the old id turns it into a "
     "compare-and-swap. THE PART PEOPLE MISS IS GIVEN ITS OWN SECTION: reachability. The stale "
     "branch was fully merged and dated 23 August, and it still pointed at the old chain (68 "
     "commits, 6,191 encrypted files in its tree) so a fresh clone after the force push still "
     "downloaded the purged objects, old commit URLs still rendered, and one fetch re-imported "
     "the lot into a clean clone (verified by doing it). Forks and refs/pull/N/head are named as "
     "the two places a branch deletion does not reach. Then what deletion does on three "
     "timescales: clones stop receiving the chain immediately; GitHub's garbage collection makes "
     "old ids stop resolving on its own schedule, or on request to Support; clones made before "
     "keep the objects forever. Recorded plainly: the branch deletion was refused three times by "
     "the session's git proxy and is pending in the GitHub UI, so the 'still downloadable' "
     "section is true as published. The recipe is written in the order that matters, and the "
     "page opens with the rule that this was housekeeping because only ciphertext was purged, "
     "for a secret, rotate first, rewrite second.",),
    ('v0.2.85', '2026-09-19', 'git 5ebdd8af',
     "HOW FAR DOWN DOES THE GRAPH GO? A DEDICATED PAGE FOR THE QUESTION THAT GOT THREE BARE URLS "
     "ON LINKEDIN. Asked whether the dimensions and layers had been defined 'all the way down to "
     "the EA and system configurations', the author answered with links to three vaults. The "
     "answer was right and the form was poor, so /demos/fractal-graphs/ now gives it properly: an "
     "eleven-rung ladder drawn as inline SVG from law to compute instance, each rung mapped to the "
     "published vault where that altitude is a live graph; the one grammar (every edge a verb with "
     "an inverse, relates-to banned, properties never carry meaning, supersede never delete) that "
     "makes the word fractal a testable claim; and walkthrough rows for seven vaults with their "
     "own screenshots and counts, Regulation Graph, Standards Atlas GDPR, AIUC-1 conformance, "
     "Risk Graph Explorer, Agentic Browser Isolation, Licence to Operate, ThreatModCon 2025, plus "
     "sibling cards for graphs.sgit.ai, standards.sgit.ai and abp.sgit.ai. THE NEW SCREENSHOTS ARE "
     "REAL: the GDPR atlas refuses to run outside a vault host, so a read-key clone was served "
     "locally behind a shim implementing sg.vfs over fetch, and its graph view was captured at "
     "three altitudes; the panel's own text, 'You are at the top of the fractal', is the page's "
     "epigraph. The vault's page gained those views too. THE HONEST SECTION IS THE POINT: the "
     "bottom four rungs (environment, runtime, compute) are modelled layers rather than imports "
     "from a CMDB or IaC; no published vault holds an enterprise-architecture repository as a "
     "graph; AIUC-1 crosswalks resolve at article level only; the GDPR atlas is a dated seed pass "
     "and uses the banned relates edge six times (counted from graph/edges.json: 6 of 227); "
     "standards.sgit.ai models one instrument and has zero crosswalks; abp.sgit.ai's capability "
     "grammar is a vocabulary not yet joined to a real grant. Named gaps get filled. ALSO FIXED, "
     "FOUND WHILE PURGING THE MIRROR: the footer on every page still said 'this site is itself "
     "served from an encrypted SG/Send vault', false since v0.2.76 and in fact never true of the "
     "deployed pages, which GitHub Pages has always served. It now says what is true: thirty "
     "vaults open in your browser with published read keys; the pages describing them are static "
     "files. Nav: Fractal graphs added under Vaults; llms.txt START HERE block points at the page.",),
    ('v0.2.84', '2026-09-19', 'git b5ae665d',
     "THE SITE'S OWN VAULT MIRROR IS GONE, DELETED FROM THE TREE, PURGED FROM EVERY COMMIT, AND "
     "THE BRANCH FORCE-PUSHED. Asked whether the mirror was still needed, the answer at v0.2.83 "
     "was no: it had no reader (every session that built the site cloned it from GitHub), no "
     "published read key (the one vault on this site nobody could open), and it had been dead "
     "since v0.2.76 without the site noticing, seven releases went out over git alone and the "
     "live pages were correct throughout. It was also 258 MB in 15,933 files against 24 MB of "
     "content in about 750: 91% of the repository. The author's call was delete, with a forced "
     "push authorised. Done with git filter-repo over all 112 commits; the pack went from 276 MB "
     "to 21 MB, and the one other remote branch (fully merged, dated August) was removed so no "
     "ref kept the old objects reachable. The purge is safe for the same reason the mirror was: "
     "everything removed was ciphertext under a key that was never in the repository, and the "
     "vault itself on the server is untouched. EVERY PAGE THAT DESCRIBED THE PATTERN AS CURRENT "
     "WAS REWRITTEN: the one-tree-two-remotes case study is now a retrospective with a 'why we "
     "stopped' section and the numbers; the why page, the admin page, the git-and-vaults note, "
     "the release-engineer role and the release prompt no longer say 'both remotes'. Historical "
     "release notes were left as the dated records they are. THE TOOLING FOLLOWED: release.sh "
     "no longer requires .sg_vault or pushes sgit (it still pulls the board vault and still "
     "refuses to finish until sgit.ai serves the new version); the key-leak tripwire now reads "
     "demo vault write keys from a gitignored admin/local/demo-keys/ folder instead of the dead "
     "vault's local tier, and check_credential.py and the publishing method point there too. "
     "The release history's commit column switches from vault commit ids to git ids from "
     "v0.2.77 on, which also filled the seven PREV_UNFILLED rows the missing key had left. THE "
     "THREE PAGES IN THE VAULTS MENU WERE STALE: the demos page listed three vaults as "
     "'published' when there were thirty, and pointed at a plan already delivered, it now leads "
     "with the gallery and keeps the three walkthroughs as walkthroughs; the catalogue, which "
     "renders a vault, turned out to hold nine entries against the gallery's thirty, so the page "
     "now says so with the date and names the gallery as the complete list until the catalogue's "
     "key holder catches up; the gallery's footer stopped calling the catalogue the place where "
     "new entries start, because for twenty-one of them it was not.",),
    ('v0.2.83', '2026-09-19', 'git 45b99758',
     "THE ROOT LLMS.TXT WAS POINTING AGENTS AT TWO 404s, AND BURYING THE GUIDANCE IT SHOULD LEAD "
     "WITH. Asked to check that an agent reading /llms.txt finds the newest guidance, the answer "
     "was that COVERAGE was complete and ROUTING was broken. The guidance front door sat at line "
     "149 of 239, as page entry 83 of 138, indistinguishable from installation.md, while the "
     "orientation block at the top still sent agents to /use-cases/ for 'task-shaped guidance and "
     "agent briefs', which is where that guidance lived before it moved to /docs/guidance/ and "
     "/docs/briefs/. None of the six scoped llms.txt files were advertised at all, so an agent "
     "reading the root had no way to learn that /docs/guidance/llms.txt or /demos/vaults/llms.txt "
     "exist. Fixed with a START HERE, BY WHAT YOU ARE DOING block before the page list (four "
     "entry points by intent, plus the list of scoped indexes) and the stale /use-cases/ pointer "
     "corrected. TWO BROKEN LINKS FOUND BY THE GUARD, NOT BY READING: the routing block is "
     "hand-written prose naming generated files, which is the exact drift this site warns about, "
     "so the build now asserts that every path the preamble points at is a file the build "
     "produces. It failed immediately on /docs/exposed-vault-key.md, the line telling agents "
     "never to write a vault key into a tracked file, pointing at a 404 for six months; the page "
     "is at /case-studies/exposed-vault-key.md. Widening the guard to every section then caught "
     "/security.md, referenced twice, where the page is /security/index.md. Both were 404 on the "
     "live site and the site's own validator had never looked, because it checks links inside "
     "pages and llms.txt is not a page. ALSO CORRECTED: the preamble claimed this site 'is itself "
     "served from an encrypted vault'. The vault mirror last moved at v0.2.76 and six releases "
     "have gone out over git alone, so the claim came out rather than being left to rot.",),
    ('v0.2.82', '2026-09-18', 'git bd8300e2',
     "VAULT #30: THE SAME PACK, WRITTEN FOR AN ARCHETYPE INSTEAD OF A COMPANY. A sibling of #29 "
     "from the same generator one day later (a fractional CISO pack, two days a month on a "
     "retainer for a company that is already certified) and the interesting thing is how it "
     "solves the publication problem. #29 described a real role and WITHHELD the company, which "
     "meant auditing whether an unnamed FTSE 250 client could be inferred from what was said "
     "about it. #30 describes A TYPE OF COMPANY in six rows derived from public sources (what it "
     "does, who buys it, where the risk sits, what it has, what it lacks, who governs it) and "
     "everything after follows from the archetype, so there is nothing to redact. The rule that "
     "generalises: a document written for a class can be published; a document written for an "
     "instance has to be scrubbed. New in this one: an ENGAGEMENT document with the monthly "
     "rhythm, a twelve-month map, and a section headed 'what two days a month is not', not "
     "cover, not a DPO, not delivery, not an audit, not a substitute for a full-time hire; a "
     "one-page INFOGRAPHIC rendered from content.json rather than drawn separately, which is the "
     "page's hero because it is the pitch on one sheet; and the three 2019 decks rendered in the "
     "viewer rather than only archived. The HONEST TENSIONS section names how the offer could be "
     "misused against the buyer (cheaper than a permanent hire, and that economy can defer a hire "
     "the company needs) which a sales document rarely does. Audit run and stated: no company "
     "named or implied, no rate or tax status ('retainer' appears as a word with no figure), one "
     "public email, no secrets, read key verified against an all-zeros control. The two packs are "
     "worth reading as a pair: the same machinery giving two answers to how you publish a document "
     "about a job.",),
    ('v0.2.81', '2026-09-18', 'git 866c39dc',
     "THE SUMMIT SHEETS GET A PREVIEW GRID, AND THEIR NUMBERS ARE DATED RATHER THAN CORRECTED. "
     "The four Lisbon handouts now appear on the parent page as four A4 previews (page one of "
     "each printed sheet, rendered from the PDF at build and shown as a card) so a reader sees "
     "all four at a glance and clicks through to the page with the table, the embed and the "
     "download. THE AUTHOR'S CALL ON THE STALE VAULT COUNT: the previous release made a point of "
     "'26 public vaults' having become 29 during summit week. The author's instruction is that "
     "the sheets are a dated record of what was said in September 2026 and should be kept as "
     "printed, so the correction became a dating note. The figures are those that were true when "
     "printed, the live table is the current answer, and the sheets are the historical one. Which "
     "is the right call: a printed artefact that gets silently re-edited to match today stops "
     "being a record of anything.",),
    ('v0.2.80', '2026-09-17', 'git 0817be5b',
     "THE LISBON SUMMIT SHEETS, AS PAGES AND AS THE PDFS THEY WERE HANDED OUT AS. Four audience "
     "sheets (founders, startups, investors, corporate) written for a startup summit and "
     "published here with a parent page, one page each, the capability table rendered as real "
     "HTML, and the PDF both embedded and downloadable. THE PDF IS THE DOWNLOAD, NOT THE PAGE, "
     "for the reason this site keeps giving: a PDF is invisible to a search engine, to "
     "llms-full.txt and to any agent reading the site, so the content is HTML and the handout is "
     "an artefact beside it. What makes them worth publishing rather than filing is the WHAT "
     "EXISTS TODAY column, which is unflattering on purpose, 'no payment link', 'not yet packaged "
     "as its own product', 'research design; implementation not claimed', 'write-only intake "
     "built, never sold'. A capability sheet that cannot say which rows are unfinished is a "
     "brochure. TWO THINGS THE CHECK TURNED UP, both while the sheets were being handed out: the "
     "PDFs are named RiskMandate.ai while every page inside them is branded sgit.ai and every "
     "footer points at sgit.ai/network, the copies here are renamed to match their contents; and "
     "the printed line '26 public vaults' is now 29, because three vaults were published during "
     "the week of the summit. The pages here do not restate the number and link the live table "
     "instead, which is the general rule worth extracting: A PRINTED ARTEFACT SHOULD POINT AT A "
     "COMPUTED ONE FOR ANYTHING THAT MOVES.",),
    ('v0.2.79', '2026-09-17', 'git 368d47d8',
     "A JOB APPLICATION AS A VAULT, AND THE PRIVACY AUDIT PUBLISHED BESIDE IT. Vault #29 is an "
     "interim CISO candidate pack delivered as an encrypted vault instead of a CV attached to an "
     "email. THE IDEA WORTH STEALING is that the pack is the evidence for its own claim: it says "
     "the candidate works with a team of agents and ships encrypted versioned artefacts, and it IS "
     "one, so a reviewer does not have to believe the claim, the thing in their hands is the test "
     "of it. Three routes split at the front door rather than one document compromising between "
     "audiences: the recruiter gets a submission summary to copy and answers to screening "
     "questions, the company gets eleven sections including BEFORE SIGNING, a conflict disclosure "
     "and one titled TENSIONS, and anyone gets the skills map and the history. Four documents each "
     "exist as PDF, Word, Markdown AND JSON, the fourth being the one that matters, a CV as "
     "structured data so a machine reading the pack gets fields rather than a page to parse. The "
     "pack's own build refuses what the vault authoring contract refuses, including a JavaScript "
     "syntax check of every inline script, the check that caught a shipped bug on riskmandate.ai "
     "the day before. THE AUDIT IS ON THE PAGE, not merely performed: the submitter's position was "
     "that the vault holds nothing sensitive, does not identify the company and reuses public "
     "material. Checked and it holds, client not named anywhere and described only as FTSE 250, "
     "no day rates or off-payroll analysis (removed before publication, with the removal disclosed "
     "in the brief's own editor's note), no phone or address, one long-public email, no "
     "credentials, read key verified against an all-zeros control. One point stated rather than "
     "waved through: thirteen named third parties appear in the recommendations with their 2019 "
     "titles, public twice over via LinkedIn and the candidate's own CC BY-SA 2019 deck which "
     "ships in the vault, labelled title_2019 throughout, and this site quotes the pack's framing "
     "rather than reproducing the individual recommendations.",),
    ('v0.2.78', '2026-09-16', 'git f4e330f6',
     "THE SYNTHETIC-USER METHOD RUNS A SECOND TIME, AGAINST A SECOND PRODUCT, AND THE SECOND RUN IS "
     "THE BETTER ARGUMENT. Vault #28 applies #27's method to riskmandate.ai one day later, which "
     "matters because one run is an anecdote and two sites is a method. It is not a repeat: the "
     "first vault NARRATED and this one MEASURES, words above the fold, character offsets, scroll "
     "positions in pixels and screens. Its headline is that THE PRODUCT IS NEVER NAMED WHERE IT IS "
     "SOLD: above the fold the home page says policy six times, insure three times and "
     "underwriters once, shows a 5 pound price, and never says Agent Behaviour Policy, which first "
     "appears 58% of the way down. Two of five read 'buy one, from 5 pounds' as buying an "
     "insurance policy for five pounds, and the one who held that reading longest was the "
     "insurance professional, the reader best equipped to recognise the vocabulary and therefore "
     "the most confidently wrong. THE CLAIM WAS RE-MEASURED RATHER THAN REPEATED: the live page "
     "was fetched, rendered at the same viewport and measured independently, and every structural "
     "number reproduces to the character, 9,270 characters, first mention at 5,387, 6,481px and "
     "7.2 screens, zero mentions above the fold. Two count lines differ and the page says why they "
     "differ rather than hiding it: a broader insur* pattern and a fold-boundary word. THE MOST "
     "USEFUL FINDING IS NOT AN OPINION: the first pass recorded a JavaScript error on EVERY page "
     "visited, both shipped, the insurance page broken since launch, and every existing test "
     "passed because the HTML still rendered and only the console knew, fixed the same session "
     "with a test added that parses every inline script and fails the build. One persona, Priya "
     "Raghavan, walks both vaults, which is what turns two studies into a comparison. NOTED, NOT "
     "FIXED: the site's sgit vault mirror is behind its git mirror, the container holding the "
     "write key was recycled, .sg_vault/local is gitignored by design, and these releases have "
     "gone out over git alone.",),
    ('v0.2.77', '2026-09-15', 'git 44323549',
     "SYNTHETIC USERS, THE 27TH VAULT, AND THE MOST USEFUL ONE HERE THAT CONTAINS NO REAL DATA. "
     "Five invented buyers were walked through store.sgit.ai one screenshot at a time, asked what "
     "they made of each screen, and interviewed at the end: 43 steps, 15 questions the site did "
     "not answer, 10 recorded confusions and 18 findings, THREE OF THEM COSTING A SALE. THE IDEA "
     "WORTH STEALING is a deliberate handicap. The agent is handed the SCREENSHOT, never the DOM, "
     "because an agent reading the DOM finds the buy button every time and therefore finds no "
     "confusion, which is the only thing the exercise is for. The protocol is published inside the "
     "vault so two runs a month apart are comparable rather than merely sequential, 'where did you "
     "guess' is a required field, and a run with no confusion anywhere is treated as a run done "
     "badly rather than a site that passed. READ THE VIEWPORT COLUMN: the persona carrying the "
     "largest decision one person makes alone on that site did the whole thing on a 390-wide "
     "phone, and produced the most questions and the most confusion of anyone, a finding the "
     "vault never states, which falls out of the table once the five runs are counted in one "
     "place. Zero page errors across all five runs, so none of the confusion was a bug; it was the "
     "copy. Four findings are already marked FIXED and kept rather than deleted, on the stated "
     "grounds that a findings list that loses the fixed ones cannot be compared with the next set "
     "of runs, and the best fix answered a request for a number with a disclosure instead, saying "
     "the duration has never run for a paying buyer so there is no measurement to quote. "
     "CREDENTIAL HANDLING: what was submitted was a VAULT KEY, not a read key. It was classified "
     "before it touched anything, the read key was derived one-way, only the derived key is "
     "published, and the derivation was proved with an all-zeros negative control that produced an "
     "empty directory against the same vault id. The one credential-shaped string in the vault is "
     "a discount code the store publishes itself.",),
    ('v0.2.76', '2026-09-10', 'git 005361e8',
     "THE TRANSFER API GETS DOCUMENTED, AND A DANGLING REFERENCE CLOSES. The SG/API team wrote an "
     "integration guide for sending an encrypted bundle to SG/Send and asked whether it belonged "
     "here. Checking rather than assuming turned up something worse than a missing page: the "
     "authentication reference already listed x-sgraph-transfer-delete-auth as one of its six "
     "headers, describing it as 'transfer deletion, SG/Send transfers, not vaults', while the "
     "API section documented ZERO transfer endpoints. The reference named a header for an API it "
     "never described, and the index's lead claimed the section was 'the protocol surface' while "
     "covering one of the two families on this host. Both are fixed: a transfers page now exists "
     "and the header links to it, and the lead says plainly that two families share the host. "
     "WHAT WAS TAKEN AND WHAT WAS NOT: the protocol is canonical and belongs here, the two "
     "secrets that must never be confused (an access key authorises YOU to upload and lives in a "
     "header; a decryption key authorises ANYONE to read and lives in the URL fragment, which no "
     "browser sends to the server), the SGMETA envelope that keeps the filename off the server, "
     "the three calls, the two traps in the create body (expires_at is MILLISECONDS, and "
     "max_downloads 0 means unlimited rather than none), the download_url that returns 404 so you "
     "build the link yourself, revocation that is opt-in at create time and impossible after, and "
     "the ~4 MB working limit with the reasoning behind it. The workflow half (credential wiring, "
     "node shapes, one product's bundle layout) stays with the product it was written for. The "
     "page opens with a TRANSFER OR VAULT decision table, because the useful editorial "
     "contribution is not restating their endpoints but saying when not to reach for a vault: a "
     "vault is the wrong answer for a handover that happens once. Their verification is "
     "attributed rather than adopted, every status code on the page was executed by them against "
     "production on 9 September 2026, and we have not re-run it.",),
    ('v0.2.75', '2026-09-09', 'obj-cas-imm-f21130f2246b',
     "A BRIEF FOR THE VAULT MAP INFOGRAPHIC, WHICH SPENDS ITS FIRST HALF ON WHY NOT TO START WITH "
     "THE PICTURE. An image-model infographic of the sites was made and is good; the ask was for a "
     "companion covering the 26 published vaults, grouped by use case and industry. Two blockers "
     "sit upstream of any image, and the brief leads with both. FIRST, THE MODEL IT COPIES HAS "
     "ALREADY ROTTED: the network infographic's footer reads '19 sites, 18 published, 1 "
     "forthcoming' and its cell for skills.sgit.ai says Forthcoming, the network now lists 27 and "
     "skills has been live for some time, so a picture about a week old is wrong in its headline "
     "number and in one of its cells. The brief turns that into design constraints rather than an "
     "objection: every count computed at generation time, the image stamped with the version and "
     "date the way a vault app is, the live table linked beside it, and regeneration on the "
     "release checklist as a diff on the vault count. SECOND, NEITHER REQUESTED GROUPING EXISTS: "
     "vaults.json carries category on all 26 (which is the vault's SHAPE, not its use case) job "
     "on only 6, and no industry field at all. So the first deliverable is two fields, not an "
     "image, with closed vocabularies the build enforces and 'cross-industry' as a real answer "
     "rather than a fabricated sector. Then the accuracy rules for a model that renders text as "
     "shapes: generate the caption list from the data first and treat the image as a rendering of "
     "it, read every string back character by character, count the cells, and let no vault appear "
     "that is not in the file, a plausible invented name being the most dangerous output the "
     "process can produce. Plus the publishing rules this site already imposes: the validator bans "
     "img src, so the data-shot pipeline applies; real alt text; and the grouped list in HTML "
     "beside the picture so the markdown twin carries substance rather than a reference to pixels.",),
    ('v0.2.74', '2026-09-09', 'obj-cas-imm-0f820c930549',
     "THE AGENT CHIP BECOMES ONE OBJECT, AND THE NETWORK CATCHES UP WITH THE ORG. The llms.txt "
     "chip shipped last release as three loose fragments (a pill, a boxed path and a long "
     "sentence) floating in dead space between the nav and the breadcrumb, belonging to "
     "neither. Four directions were drawn against the site's real tokens and one was chosen: a "
     "SINGLE BORDERED CONTROL with a tinted FOR AGENTS cell, the path in mono as the only "
     "emphasised element, and the version and date behind a dashed rule. It reads as one thing "
     "you can click rather than three things you cannot. The always-on sentence moved into the "
     "link's tooltip, it renders on all 124 pages, and as visible text it was instruction noise "
     "sitting above every headline on the site. THE NETWORK LIST WAS EIGHT SITES BEHIND. Checked "
     "against the organisation's 31 repositories rather than against memory: games, "
     "what-can-it-do.games, providers, ungovr.providers, elevenlabs.providers, teams, "
     "threat-modeling and chrome-extensions all had repositories, all answered 200, and none was "
     "listed. Each entry is written from what that site says about itself (its own title, "
     "description and version, fetched) rather than from a guess. TWO CORRECTIONS FELL OUT OF "
     "THE CHECK: skills.sgit.ai was still described here as 'GitHub Pages has not published yet, "
     "so there is nothing to read at the address', and it has been live for some time, the "
     "entry now carries its real thesis and version; and the ElevenLabs repository describes its "
     "domain as elevenlabs.provider.sgit.ai, singular, which does not resolve, the live host is "
     "elevenlabs.providers.sgit.ai. The site list is 27.",),
    ('v0.2.73', '2026-09-09', 'obj-cas-imm-5569e595ea36',
     "THE LLMS.TXT STOPS BEING A URL YOU HAVE TO GUESS. Six llms.txt files are published across "
     "this site and the only way to find one was to type it onto the end of an address. Every "
     "page now carries a small chip above its title naming the one that covers it, FOR AGENTS, "
     "the path, and the version and date it was generated. Resolution is deepest-folder-wins, so "
     "a page under /docs/vault/ points at that section's index rather than at /docs/llms.txt, and "
     "a page with no closer index falls back to the site-wide one; every page therefore has "
     "exactly one, and it is always the most specific one that exists. THE STAMP IS THE POINT, "
     "not decoration: these files are regenerated on every release, so the chip says which "
     "release produced the index a reader is about to fetch, and an agent editing a page can "
     "see at a glance whether the index has caught up with it. The chip is chrome rather than "
     "content, emitted between the nav and the page body, so it does not reach the markdown "
     "twins and cannot drift from them. A build assertion now fails the build if the chip ever "
     "points at a folder no generator actually writes, which is the failure this feature would "
     "otherwise introduce quietly. CAUGHT BY BUILDING IT: the guidance page still SAID "
     "/guidance/llms.txt in two places after last release moved the file to /docs/guidance/, "
     "the href had been rewritten by the move script, the prose had not, so the page displayed "
     "a path that 404s while linking correctly. Fixed. A reminder that a link rewriter fixes "
     "links and not the sentences around them.",),
    ('v0.2.72', '2026-09-09', 'obj-cas-imm-92130ad84859',
     "ONE FRONT DOOR FOR VAULT GUIDANCE, AND EVERY DOCUMENT MOVED UNDER /docs/. The guidance had "
     "accumulated across three top-level folders (/briefs, /vault and a new /guidance) which "
     "is three places to look for one kind of thing. Everything readable now lives under /docs/: "
     "/docs/briefs, /docs/vault, /docs/guidance, alongside the existing CLI docs. Fourteen pages "
     "moved, links rewritten by resolving each one through the move map rather than by prefixing "
     ", a blanket prefix would have broken every link that stayed inside the moved subtree. No "
     "redirects, by instruction: the estate is in flux and has few external users. Two "
     "hand-written .md briefs that live only in the built output, not in the content tree, were "
     "nearly lost to the cleanup and were restored from git into the new location. WORKING ON A "
     "VAULT: START HERE is the front door that did not exist, and the first thing on it is the "
     "guidance that gets repeated to agents most often: VERSION EVERYTHING, SHOW THE VERSION, "
     "LINK WHAT CHANGED. The number belongs in the app chrome, small and always visible, and it "
     "must link to that version's OWN details rather than a generic changelog; versions live in "
     "the vault as versions/index.json plus one file per version, each naming the commit it was "
     "built from and saying whether it was recorded or reconstructed. The AIUC-1 conformance "
     "vault is named as the reference implementation because it already does exactly this. Seven "
     "more practices follow, each with the failure that produced it. /docs/guidance/llms.txt is "
     "the same thing shaped for an agent: five rules, a reading order, the briefs, reference "
     "implementations to go and check, and edges out to coding.sgit.ai, nfrs.sgit.ai and "
     "graphs.sgit.ai, because a page about vaults should not try to also be the style guide, "
     "the resilience argument or the grammar of semantic graphs. The page closes by saying why "
     "it is mostly links, quoting graphs.sgit.ai's thesis back at itself: a node is just a node, "
     "meaning lives in the edges.",),
    ('v0.2.71', '2026-09-09', 'obj-cas-imm-771aa698c057',
     "GUIDANCE NOW SPLITS BY SURFACE, AND THE THIRD SURFACE FINALLY HAS ITS OWN BRIEF. The "
     "guidance on this site had been organised by TASK (decks, markdown, file viewers) which "
     "hid the fact that every one of those questions has three different right answers "
     "depending on where the code runs. THREE SURFACES is the new router: _page.json inside a "
     "vault, an HTML vault app inside a vault, and a page on a *.sgit.ai site outside every "
     "vault host, compared on where they run, who reads the vault, which credential is used, "
     "what the reader needs, whether search engines and agents can see them at all, and, the "
     "row people get wrong, TRUST DIRECTION. Inside a vault host the host protects the reader "
     "and sandboxes the app; on a site page there is no host, you ARE the host, and vault bytes "
     "are untrusted input arriving in your origin. A second table does the same job across the "
     "three surfaces: markdown, file browsing, decks, PDFs, computing over data, and being "
     "findable by someone who has never heard of you, which only one surface does at all. "
     "READING A VAULT FROM A SITE PAGE is the brief that did not exist, for the devs coding the "
     "estate's sites. It leads with the enabling fact: the vault API answers plain CORS GETs "
     "with NO AUTH HEADER from any origin, which the server can afford because it returns "
     "ciphertext under a key it never held, so no proxy and no backend. It says do not write "
     "the reader and names the four house files to copy instead, states the inverted trust rule "
     "with a table of how to render each kind of vault content, covers the ref-caching trap that "
     "fails silently by rendering an older commit from valid ciphertext, and says what should "
     "NOT go on a site page: duplicated vault prose, a rebuilt vault app, and any arrangement "
     "where the site is the only way to read the vault. SIX GUIDANCE PAGES ARE NOW LABELLED "
     "with the surface they apply to, so the distinction is visible where someone lands rather "
     "than only on the router.",),
    ('v0.2.70', '2026-09-09', 'obj-cas-imm-0a8b264f2c4e',
     "THE SECOND BUILD BRIEF, AND IT MOSTLY SAYS DO NOT BUILD IT. Two of the most common things "
     "an agent is asked to add to a vault, a markdown viewer and a file/folder browser with raw "
     "views, already exist in the platform, and most requests for them are answered by "
     "publishing files in the right shape and writing no code at all. The brief is therefore a "
     "DECISION rather than a syntax reference: a four-rung ladder (publish .md and stop; add a "
     "_page.json if you need a designed page rather than a document; build an app only when a "
     "view must COMPUTE something the host cannot know; build a site viewer only when the "
     "content must live outside a vault host) with the instruction to stop at the first rung "
     "that works. It names the three surfaces that render markdown natively, and the rules that "
     "actually catch people rather than the full syntax: raw HTML is stripped and shows as "
     "escaped text, images size through the pipe syntax inside the alt text, folder links must "
     "go through folder/README.md because a bare folder link resolves by sort order, nested and "
     "task lists are unsupported. THE RAW-VIEW CONTRACT, taken from a vault that already lives "
     "by it: the AIUC-1 conformance vault's own source says 'raw is the point, a catalog that "
     "asks to be trusted has to be readable in the form it was written', and the brief adopts "
     "that whole, raw always available for every file, a reader as an addition and never a "
     "replacement, the tree driven by a build-time manifest, files fetched on click. Generalised "
     "as the estate's habit: anything rendered should be one click from the thing it was "
     "rendered from. CORRECTED WHILE CHECKING THE SCHEMA: the content-authoring page said "
     "ELEVEN component types and then listed twelve. It says twelve.",),
    ('v0.2.69', '2026-09-09', 'obj-cas-imm-d896dff05cc1',
     "THE BUILD BRIEF FOR PUTTING A VAULT'S DECKS ON A WEBSITE. v0.2.67 documented the "
     "MECHANISM, reading one file out of a vault, and v0.2.68 built the pages, but there was "
     "nothing telling another agent how to do this to THEIR vault. There is now: a build brief "
     "in the same shape as the telemetry one, which is the shape that worked when another "
     "team's agent built from it. It states the decks/v2 contract a vault must publish (the "
     "manifest and where it may live, deck sources pushing t/notes/html onto S, screenshots "
     "named symbolically rather than pathed, the CSS in the shell's style block, the PDFs), the "
     "rule that governs the whole design, A VAULT MUST BE ABLE TO CHANGE WHAT IS SHOWN AND "
     "NEVER WHAT THE PAGE DOES, the two sandboxed frames with their exact CSPs, why the deck "
     "source must be handed over by postMessage rather than baked into a srcdoc, and why a "
     "decrypted PDF cannot go in an iframe at all. It publishes BOTH BUGS we hit rather than "
     "only the finished design: the image-name pattern that excluded underscores and failed "
     "silently on fifteen slides, and the unscoped closest() that killed every button on the "
     "single-deck pages. It ends with a done-means checklist and a prompt to hand the builder. "
     "The reference page now points at the brief for anyone who arrived wanting instructions "
     "rather than an explanation. Caught in review: the .md twin rule strips inline code spans "
     "but not fenced blocks, so tag names inside the prompt failed the build, rewritten to the "
     "site's uppercase-placeholder convention, which is the third time that rule has earned its "
     "keep.",),
    ('v0.2.68', '2026-09-09', 'obj-cas-imm-b7b0f80752be',
     "A PAGE PER DECK, A FOCUS MODE, AND SCOPED LLMS.TXT FILES. Eleven new pages: each of the "
     "nine published decks now has one of its own (four under the AIUC-1 conformance vault, "
     "five under Licence to Operate) plus an index for each vault. A deck page opens that deck "
     "alone, with the tab strip gone, and carries what the slides cannot: the level it answers, "
     "the vocabulary it introduces, who it is for, where it deliberately stops, the question it "
     "ends on and which deck picks that question up. Each has a NOTES ON THIS DECK section that "
     "is deliberately empty and says so, that room is the reason a deck deserves a page rather "
     "than a tab. FOCUS drops the slide list so the stage takes the full width, for presenting "
     "and for screen recording, and the stage is re-fitted rather than merely revealed. A BUG "
     "THE PER-DECK PAGES EXPOSED IMMEDIATELY: the mount element carries data-deck on a "
     "single-deck page, and the click router used an unscoped closest('[data-deck]'), so it "
     "matched the mount for every click inside the viewer, prev, next, notes, focus and the "
     "PDF button were all silently dead on exactly the pages just built. Scoped to the tab "
     "strip. SCOPED LLMS.TXT: /demos/vaults/llms.txt is the catalogue as an agent index (all "
     "26 vaults with id, category, published date, size, file count and published read key) "
     "generated from vaults.json, the same file the table on that page is built from, with each "
     "read key lifted from the vault's own page rather than kept in a second list that could "
     "disagree. Vault keys appear nowhere, as always. Three more sections got one: /vault, /api "
     "and /docs.",),
    ('v0.2.67', '2026-09-09', 'obj-cas-imm-daf0dde2fed2',
     "DECKS AND PDFS READ STRAIGHT OUT OF A VAULT, WITH THE VIEWER OWNED BY THE SITE. The vaults "
     "publish presentations (four on the AIUC-1 conformance page, five on Licence to Operate) "
     "and until now the only way to see them was to open the vault's own app. They now play on "
     "the vault pages themselves, fetched as ciphertext with the read key already printed at the "
     "top of each page and decrypted in the reader's browser. THE SPLIT IS THE POINT: from the "
     "vault come the manifest, the slide sources, the speaker notes, the screenshots and the "
     "printed PDF; from the site come every control you can click (deck tabs, slide list, prev "
     "and next, notes, the PDF button, the deep links) so a vault decides what is SHOWN and "
     "never what the page DOES. Vault content reaches the page through two opaque-origin frames: "
     "a deck builds its slides by running JavaScript, so it runs in a sandboxed frame with "
     "default-src none and posts back a plain array, and the slide markup then renders in a "
     "second frame with scripting switched off entirely. WHY THE PDF IS A DOWNLOAD, MEASURED "
     "RATHER THAN ASSUMED: Chrome refuses to render a PDF in a sandboxed frame at all ('failed "
     "to load as a plugin, because the frame into which the plugin is loading is sandboxed') "
     "tested across every sandbox combination, so an inline PDF would mean handing vault bytes "
     "this origin. The bytes are decrypted in the page and handed to the browser's own download "
     "instead; a 2.1 MB PDF arrives byte-identical. Two published deck shapes are handled, "
     "because the two vaults differ: one shipped its sources, the other only its built decks. "
     "A REAL BUG FOUND BY WALKING EVERYTHING: the first image-name pattern excluded underscores, "
     "so fifteen slides rendered with a silently missing screenshot and no error anywhere, all "
     "113 slides across the nine decks are now walked in the browser test, and the count of "
     "missing images is zero. New page: READING ONE FILE OUT OF A VAULT, the primitive under "
     "every live embed here, written down on its own with the sandbox rules for what comes back.'"),
    ('v0.2.66', '2026-09-09', 'obj-cas-imm-d2f92005581d',
     "THE SHORTS PAGE REWRITTEN FOR SOMEONE WHO ARRIVES WITH NO CONTEXT. A video page is a landing "
     "page whether or not it was designed as one: traffic comes from a feed, lands on it directly, "
     "and has never seen the vault, the site or the argument. What sat above the seven players was "
     "an apology about missing transcripts and a note explaining why a row number in the table "
     "could not go stale, two pieces of internal housekeeping addressed to readers who already "
     "knew everything the page had to sell. Both are gone from the top. In their place: FOUR "
     "WORDS, AND THE GAP BETWEEN TWO OF THEM, grant, mandate, delta, licence to operate, defined "
     "in a table with the counts from the demo beside each (12 capabilities, 4, 8, priced per "
     "turn), so the whole model is legible before a single video plays. Then two calls to action, "
     "in the order a cold visitor needs them: OPEN THE VAULT, with its read key printed as the "
     "whole credential and both routes offered (its page here, or straight into the vault UI); and "
     "RISKMANDATE.AI, named as where this goes commercially, the business risk layer for "
     "autonomous systems, every autonomous system mapped to its blast radius, given a business "
     "owner and driven to a time-bound decision: accept, fund, or fix. The vault is the "
     "demonstration; RiskMandate is the product it demonstrates, and the Risk Graph Explorer is "
     "the same engine already published as a vault. THE TRANSCRIPT GAP IS NOT HIDDEN, IT IS "
     "RIGHT-SIZED: still stated in the footer, in one sentence, with the board card still open. A "
     "gap worth disclosing was not worth the first screen. Nothing changed about the seven videos, "
     "their order, or the three movements."),
    ('v0.2.65', '2026-09-09', 'obj-cas-imm-9407711950c8',
     "SEVEN SHORTS ON THE LICENCE TO OPERATE VAULT, INDEXED AND PUT IN ORDER. The author recorded a "
     "vertical-video series walking through vault posrhzp3; none of the seven was on the site. They "
     "now have a page under the vault, collected in the order they are meant to be watched rather "
     "than the order a feed would show them: THE MECHANISM (1-3: grant against mandate, what a "
     "block looks like, who in the organisation accepts which risk), THE ARTEFACT (4: how to find "
     "and open the vault, the one to send someone who wants to poke at it), THE MODEL (5-7: the "
     "gap as the place risk lives, the insurance framing, then policies, claims and premiums). "
     "Each carries the author's own description, lightly edited, plus a line mapping it to what it "
     "demonstrates in the vault or the estate, the delta for 5, the AIUC-1 conformance layer's "
     "insurability query for 6, risks.sgit.ai's no-deny-button position for 7. WHAT THE PAGE "
     "REFUSES TO PRETEND: these are descriptions, NOT transcripts. All four of YouTube's timedtext "
     "endpoints return empty for every one of the seven, so there is no caption track to pull and "
     "the words spoken are absent from the site, from llms-full.txt and from the chat pane's "
     "read_page tool. That is the exact failure the risk-graph-explorer walkthroughs page was "
     "built to avoid, so the gap is stated in a box at the top, measured rather than guessed, and "
     "opened as board card T12. A NUMBER THAT CANNOT ROT, DEMONSTRATED: video 4 tells the viewer "
     "to look for 'Vault #23'. Checked, Licence to Operate is still #23 and always will be, "
     "because v0.2.58 made that column a permanent publication ordinal rather than a row position. "
     "A recorded video naming a row number would have been stale within a day; naming an identity "
     "is safe. Embeds go through youtube-nocookie.com with loading=lazy, so opening the page sets "
     "no YouTube cookie until somebody presses play. New portrait 9:16 player CSS, since the "
     "existing .vembed is a 16:9 box and a Short in it is two black pillars."),
    ('v0.2.64', '2026-09-07', 'obj-cas-imm-80636cf90122',
     "THE BOARD MOVES INTO A VAULT OF ITS OWN, AND ITS READ KEY IS PUBLISHED. Two releases after the "
     "board appeared as files in the site repository, it was clear the shape was right and the home "
     "was wrong: moving a card from review to done cost a full site release. So the seventeen cards "
     "moved into vault pdulwi6i, issues/<id>-<slug>.md, a README with the card format, a "
     "five-column board app (index.html, permissions {}) that lists issues/ through sg.vfs and "
     "falls back to issues/index.json when served outside a host, and tools/reindex.py to "
     "regenerate that index. THE VAULT IS THE TRUTH; THE SITE IS A READER: release.sh now pulls "
     "the vault before it builds (step 0), the loader reads admin/content/team/issues/issues/, "
     "which IS the vault's working tree, cloned in place with its encrypted store gitignored and its "
     "card files tracked as the build's input, and the board page labels its columns as a "
     "snapshot at the site version, with the read key and one open-live button above them. Moving "
     "a card is now sgit push, and the board is live the moment it lands; the site catches up at "
     "its next release. PUBLISHED AS ROW #26 on the vaults table with its own page, following the "
     "method: the write key escrowed in the gitignored tier, the audit run (nothing secret-shaped "
     "beyond the read key in its own README), the app screenshotted by driving it, permissions "
     "stated. The Sherpa's board prompt and the team page's 'where things are' now point at the "
     "vault. One design note: the app works in two places on purpose (under a vault host it reads "
     "the files directly and needs no index, served flat it reads the index) because a board that "
     "only renders inside one host is a board nobody can screenshot, test or read from a script."),
    ('v0.2.63', '2026-09-07', 'obj-cas-imm-d523226f4c7a',
     "A CHAT PANE ON EVERY PAGE WHOSE MODEL CALLS TOOLS OVER THIS SITE'S OWN CONTENT. Asked for the "
     "pane the sibling sites have; built the stronger version of it. 'Ask this site', bottom right "
     "of every page, three tiers like the network chooser. TIER 0, no key: seven tools run directly "
     "in the page, type words to search everything (pages, vaults, sibling sites, release notes, "
     "board cards), or /vaults, /sites, /updates, /board, /read PATH, /here, instant, private, no "
     "network after the index loads. TIER 1, bring-your-own OpenRouter key: the model is handed the "
     "same seven tools as OpenAI-style function definitions (search_site, read_page, list_vaults, "
     "list_sites, latest_updates, get_board, current_page) and calls them; every call executes "
     "HERE over the build-emitted index and the .md twin of any page, so the model can only say "
     "what a tool returned, and THE PANE SHOWS EVERY CALL IT MADE as a trace line. Up to six tool "
     "rounds, then a plain-prose answer ending in the paths it used, as links. TIER 2, sg.llm inside "
     "a vault, is detected-not-wired and on the board as T11, blocked on one fact about the bridge's "
     "tool contract. THE INDEX is assets/site-index.json, written beside llms.txt from the same data "
     "as the vaults table, the network directory, the feed and the board, one derived file, so an "
     "answer in the pane is an answer the site already gives somewhere. LOADED THROUGH THE BOOT "
     "BLOCK after site.js, by the same vfs-or-fetch loader, with the loader exposed as "
     "window.__sgitBoot so the module resolves the index and the twins the same way on a blob: "
     "origin; never a script src. The key goes to openrouter.ai and nowhere else and the pane says "
     "so in the same words the chooser uses. Model output is escaped and links are allowed only to "
     "http(s) or paths on this site. The validator now parses the chat modules too. The chat-on-a-"
     "static-site article gains an addendum and its 'shared component: not started' row becomes "
     "'partly': one site's copy, not yet the versioned module the other eighteen could load."),
    ('v0.2.62', '2026-09-07', 'obj-cas-imm-e27526f11c99',
     "TWO SECTIONS: THE TEAM, FOR THE AGENTS; AND INVESTORS, IN THE OPEN. Asked for a full agentic "
     "section briefing agents on how to work on this site, with roles and pages like the sibling "
     "sites, a board like the ones done before, and the starting prompts for the regular work, "
     "plus an investor section on the same open-materials model the founder's other companies use, "
     "for an event next week. THE TEAM (team/): nine roles, each a file under "
     "admin/content/team/roles/ with mission, what it owns, what it must not touch, the files it "
     "works in, the checks it runs, the rules it enforces WITH THE MISTAKE THAT PRODUCED EACH, and a "
     "starting prompt; the grid and the nine pages derive. The roles mirror the Explorer team in the "
     "CLI repo, specialised for running a site: Sherpa, Publisher, Auditor, Journalist, Cartographer, "
     "Ambassador, Designer, Release engineer, Historian, the Publisher and Auditor exist because "
     "this site publishes read keys on purpose. TWELVE STARTING PROMPTS (team/prompts.html) for the "
     "work that recurs, publish, audit, update, article, sibling site, inbound brief, release, phone "
     "bug, markup-to-data, correction, board, read-key sweep, each written to be pasted into a fresh "
     "agent, each ending before the release step on purpose. THE BOARD (team/board.html): issues as "
     "files under admin/content/team/issues/, five columns rendered from a status line, Needs (only "
     "the author can supply) kept apart from Tasks, in the spirit of issues-fs.sgit.ai and the comms "
     "board on open-source.sgit.ai; seeded with sixteen real items including the six things only "
     "the author can answer. INVESTORS (investors/): the structure of investor.myfeeds.ai, problem, "
     "what it is, architecture, traction, business model, market, the ask, use of funds, what could "
     "go wrong, materials, with traction COMPUTED from the site and THE ASK LEFT VISIBLY OPEN in a "
     "dashed box tracked as board item N1, because nothing on that page may be a number the founder "
     "has not supplied; the business-model section quotes the founder's published open-source "
     "position through a sibling-site card rather than restating it. NAV: Try folds into Docs; Why "
     "becomes a group (Why, Investors); Team is a new group (overview, roles, prompts, board), eight "
     "top-level items, as before. TWO BUILD LESSONS: the content loader gained a generic "
     "one-file-per-thing reader (roles and issues are three lines each); and angle-bracket "
     "placeholders inside code spans broke the markdown twin twice while being regex-wrapped, so "
     "the whole team section uses UPPERCASE placeholders with no angle brackets, one convention, "
     "every renderer. Verified: 105 pages, validator clean, no overflow at 390 on any new page."),
    ('v0.2.61', '2026-09-07', 'obj-cas-imm-595486afe042',
     "A CORRECTION TO THE RELEASE BEFORE IT, CAUGHT BY COUNTING. v0.2.60's article, its update post "
     "and its version-log entry all said the homepage went from nine bands to eight. It did not: "
     "git show of the previous content file counts nine sections, the new one counts nine, three "
     "were cut (features folded into the walkthrough, the abstract use cases, the three doors) and "
     "three were added (the hero vaults, what people ship, the team). What changed is the ORDER "
     "and the first screen, not the length; words went 1,370 to 1,332. The claim was written from "
     "memory of the plan rather than from the file, which is exactly the failure this site says "
     "it does not permit, so all three places now carry the corrected count and the article says "
     "in its own text that it was corrected and why. Second catch, same class: the update post restated the "
     "team band's four numbers in prose and was off by one within the hour, because a release had "
     "happened; the prose now names the numbers without repeating them. Nothing else changes."),
    ('v0.2.60', '2026-09-07', 'obj-cas-imm-19a2bbeb1375',
     "THE HOMEPAGE REBUILT: PROOF BEFORE MECHANISM. Follows the diagnosis published in v0.2.59 "
     "rather than a fresh opinion, so the two can be compared, and the 'after' article puts each "
     "new band beside the screenshot of what it replaced. HERO: the sentence changed from 'the "
     "encrypted git for humans and AI agents' to 'a vault is a unit of work: data, app, history "
     "and sources, shipped as one string' (encryption becomes the subordinate clause, which is "
     "where a property nobody can look at belongs) and FOUR REAL VAULTS sit directly under it, "
     "screenshot each, one click from open, chosen by a hero field in vaults.json so changing "
     "the front door is a data edit. NEW BAND 'what people actually ship': six vaults chosen by "
     "the JOB they do (hand over a report, publish a standard as data, give a talk, pitch an "
     "investor, ship a game that reports back, give an agent a workspace), each with one line on "
     "why it is hard any other way, and none of those lines is about encryption. NEW BAND 'one "
     "human, a team of agents': four numbers COMPUTED at build (releases from the version log, "
     "vaults from the data, sites from the network directory, briefs by counting the briefs page) "
     "and the collaboration loop told in three beats with the artefacts linked. CUT: the abstract "
     "use-case band (the pages remain, in the nav), and the 'three doors' band (now one pill in "
     "the trust strip pointing at the page that already explained it). MOVED DOWN: the terminal "
     "walkthrough, under a heading ('under the hood, it is git') because for a visitor who has "
     "just opened a real vault, how is now the question; it gained the feature card 'apps live "
     "inside the data', which was never on the list. The band count did NOT change (nine before, "
     "nine after; three cut, three added) so what changed is the order and the first screen, not "
     "the length; bytes went UP because ten screenshots replaced paragraphs, words went down 1,370 "
     "to 1,332. IMAGES GO THROUGH shots.js, never a static image src attribute: "
     "the validator refuses a static src because a relative one does not resolve inside a vault, "
     "and the build injects the loader wherever data-shot appears. Card text carries hidden "
     "separators so the .md twin reads 'Reference, name, line, open it' instead of one run-on. "
     "ONE CORRECTION CAUGHT BY THE COMPUTED NUMBER: the network heading had been retyped as "
     "'Twenty sites' while the tile said 19. The tile was right (nineteen siblings; twenty with "
     "this one). THE GAP STANDS: no published vault yet shows two agents on one vault with a "
     "human merge, and the team band is written not to pretend otherwise. Verified at 1400 and "
     "390 wide: 10 figures loaded, 0 failed, no horizontal overflow."),
    ('v0.2.59', '2026-09-07', 'obj-cas-imm-ddba86527bec',
     "THE DIAGNOSIS BEFORE THE REBUILD, PUBLISHED AS AN ARTICLE, AND A CARD FOR POINTING AT THE "
     "SIBLING SITES. Asked to step back and say how the site should present the twenty-five "
     "vaults, the answer was a diagnosis before a redesign: the homepage leads with encryption, "
     "which cannot be looked at, and a terminal walkthrough of commands every git user has seen; "
     "the twenty-five artefacts a stranger can open in one click sit two clicks away as a table; "
     "the use cases are categories while concrete examples of each exist one level down; and the "
     "strongest story, agents building for agents, a brief published here turned into a vault "
     "the same day and reviewed by the team that owns the API, is filed under Docs as a log. "
     "One gap named plainly: the homepage's strongest multi-agent claim, a branch per agent and a "
     "human merge, has NO published vault behind it. The article carries the evidence as "
     "screenshots of the current site, captured before anything changed so the 'after' article "
     "can be compared against them honestly, and sets out the fix in order: proof before "
     "mechanism, reorder and cut rather than add. NEW DIRECTIVE, !site, a card for referencing a "
     "page on another *.sgit.ai site. Author writes host | path | title [| line]; the card pulls "
     "that site's category and thesis from its entry in the network directory, so it describes "
     "the site the way the site describes itself, and carries a 'part of the sgit.ai network' "
     "cue a bare link cannot. The whole card is the pointer target via the title anchor's "
     "::after, so the markup stays a plain link for the .md twin and for screen readers. Debuts "
     "in the article pointing at open-source.sgit.ai/about, which is also the decision recorded "
     "there: this site gets an About page about sgit, and links to the fuller record rather than "
     "duplicating it. An unknown host degrades to host + title with no lookup."),
    ('v0.2.58', '2026-09-07', 'obj-cas-imm-b61899966f9d',
     "THE SG/API TEAM REVIEWED OUR BUILD BRIEF AND FOUND IT WRONG, SO THE CORRECTION IS NOW MORE "
     "PROMINENT THAN THE MISTAKE. Two days after v0.2.55 published the telemetry brief, an agent "
     "built the games vault from it and could not get events out. The team that owns the append "
     "code read it against the brief and returned a line-referenced review. Both of the things "
     "this brief told a builder to VERIFY, it had already answered wrongly. (1) We said "
     "sg.append.write fails closed in a read-only session: it does not, there is NO read-only "
     "gate on append anywhere, and permissions.append.write is the entire requirement. (2) We "
     "steered to a direct fetch instead, which is blocked by the frame's default "
     "connect-src blob: data: escapable only by permissions.network, which the reviewer notes "
     "appears 'zero times' in the authoring guide and zero times here, and which is the WRONG fix "
     "because it reopens every egress from a frame holding decrypted vault content. The brief now "
     "recommends the bridge, carries the correction in a box above the original section, and its "
     "hand-to-the-builder prompt is reversed. THIS RESOLVED AN OPEN FINDING: the games vault's "
     "telemetry is built and never sent, because its app.json declares no permissions at all, "
     "which matches the author's report that nothing arrived, and replaces the 'unresolved' note "
     "we published rather than guess. Its page now leads with that status instead of claiming it "
     "phones home. THREE FACTS THAT EXISTED NOWHERE PUBLIC are now on the API reference: only "
     "write takes a vault_id while the other five verbs bind to the currently open vault, so "
     "cross-vault listing is impossible by design; fetch maps to append.read, not append.fetch; "
     "and the inbox field in a listing is the lane folder, which today is the raw append token "
     "while config stores its hash. A follow-up of seven questions went back, which hosts serve "
     "the append routes, whether the enum-key derivation is stable enough to publish as a spec, "
     "and whether any non-destructive way exists to tell an append token from a read key, since "
     "they are the same shape and confusing them would be a serious leak. THE # COLUMN IS NOW AN "
     "IDENTITY, NOT A ROW POSITION: it was renumbering 1..25 on every sort, which said nothing. "
     "It is a permanent publication ordinal, 1 is the first vault ever published here, so it "
     "never changes, and sorting by it is by construction the same order as sorting by date. "
     "Asserted in the check rather than eyeballed."),
    ('v0.2.57', '2026-09-06', 'obj-cas-imm-27f13ad369ff',
     "THE VAULTS TABLE STOPS BEING A KEY DUMP AND BECOMES SOMETHING YOU CAN SORT. Reported from "
     "an iPad, where the failure was obvious in a way it never is on a desktop: the read-key "
     "column is a 64-hex string, and giving it room squeezed the vault id down to ONE CHARACTER "
     "PER LINE, 'ookq4mn4' rendered as a vertical stack. The fix was not narrower columns, it "
     "was noticing that the two widest columns were the two nobody needs on an index: the read "
     "key and the 'open live' link are both already on each vault's own page, one click away. So "
     "the index now answers 'which of these do I want' and the vault page answers 'how do I open "
     "it'. GONE: read key, open live, and the dense contents blob. NEW: a # column, a one-line "
     "WHAT IT IS, a CATEGORY pill (8 categories over 25 vaults), files and size as separate "
     "numeric columns, and a PUBLISHED date. CLICK ANY HEADING TO SORT, default newest-first "
     "because the recent ones are the interesting ones. The count line states the total and the "
     "category breakdown, which is worth seeing on its own. GENERATED, NOT HAND-WRITTEN: 25 hand-written table "
     "rows could not be sorted, counted or kept consistent, so the table comes from "
     "admin/content/vaults.json through a VAULTS comment marker, the same mechanism the homepage "
     "articles band uses. The published date is NOT a date anybody typed. It is the commit date "
     "on which each vault's page first appeared in git, recovered with git log --diff-filter=A, "
     "after two weaker sources were tried and rejected: update posts missed ten vaults and "
     "false-matched health-score against a later release that merely mentioned it, and the "
     "version log had no line for two of them at all. SORTING IS PROGRESSIVE ENHANCEMENT: rows "
     "ship newest-first in the HTML, so with JavaScript off the table is still correct and still "
     "in the most useful order; the headings are keyboard-operable. On a narrow viewport the "
     "'what it is' column drops out rather than wrapping to nothing, because that sentence is on "
     "the vault's page too. Verified at 1280 and 390 wide: 25 rows, zero 64-hex strings on the "
     "page, zero 'open live' links, no horizontal overflow, and sorting checked by asserting the "
     "order actually flips."),
    ('v0.2.56', '2026-09-06', 'obj-cas-imm-dd4f74725ece',
     "THE BRIEF CAME BACK AS A VAULT, AND IT IS THE FIRST ONE PUBLISHED HERE THAT PHONES HOME. "
     "Two days after v0.2.55 published the build brief on telemetry from a published vault, "
     "another agent read it and shipped the thing: two deterministic games about grants, "
     "permissions and mandates, sending anonymous usage events over an append lane to a separate "
     "private vault. That makes this the first end-to-end test of whether a brief written for an "
     "agent produces what it describes, and the answer is yes, including the part the brief was "
     "least sure about: it took the FALLBACK path, a direct fetch to the account-less write "
     "endpoint with credentials:'omit' and keepalive on the final flush, rather than sg.append "
     "through the bridge, which is what the brief said to do if a read-only session fails "
     "closed, and app.json declaring no permissions key at all is consistent with that. "
     "CREDENTIAL AUDIT, run against the brief's own claims rather than the vault's self-"
     "description: no private keys, no enum/write/vault/read key fields, no third-party secrets, "
     "no personal data, and EXACTLY ONE 64-hex string in the whole vault, the append token, in "
     "five places. It was tested rather than trusted, because an append token and a read key are "
     "both 64 hex and a mistake between them would be invisible: cloning the telemetry vault with "
     "it yields no clone_mode.json and decrypts nothing. A METHOD CORRECTION THAT COST NOTHING "
     "ONLY BECAUSE THE CONTROL WAS RUN: the first version of that test called a LEAK on the "
     "strength of sgit clone creating a directory, then an all-zeros key produced the identical "
     "result, proving the directory is created regardless and the test discriminated nothing. The "
     "marker that actually discriminates is .sg_vault/local/clone_mode.json. Any credential test "
     "that has no negative control is not a test; this now goes in the publishing method. TWO "
     "FINDINGS PUBLISHED RATHER THAN FILED: (1) two of the four pages still tell the player "
     "'nothing sent', what-can-it-do was fixed to say 'nothing stored', the home page and "
     "which-agent-is-it were missed, and the latter contradicts itself on a single screen; "
     "nothing leaks, but the subject of the vault is informed consent, which raises the stakes on "
     "leftover copy. (2) The write endpoint could not be confirmed from this container, 404 on "
     "both hosts under both the append and inbox names, while inbox/list returns 403 on "
     "production, which our own API page says is gone. One data point, recorded as unresolved "
     "rather than dressed as a conclusion. Screenshots were captured with the telemetry aborted "
     "at the network layer so photographing the games did not put junk in somebody's real lane."),
    ('v0.2.55', '2026-09-06', 'obj-cas-imm-5e5e76eaf7d0',
     "BRIEFS BECOMES A SECTION WITH TWO KINDS IN IT, AND MOVES FROM EVIDENCE TO DOCS. The /briefs "
     "page has been a single scroll of cross-team asks since v0.1.13, each addressed to another "
     "team, each with a status, each closing when answered. A brief arrived that is a different "
     "species: not a request to anybody, but a durable reference an agent executes, on how one "
     "vault sends messages to another and how a vault whose read key is public reports anonymous "
     "usage back to its author. Filing it as another ask would have been wrong, so the page now "
     "declares the two kinds and separates them: BUILD BRIEFS (executed, never close) above "
     "CROSS-TEAM ASKS (addressed, status-tracked), with the seven existing asks untouched and "
     "demoted one heading level. The new group starts with two entries rather than one, because "
     "demos/vaults/publishing.html was already a build brief, 'written to be followed by another "
     "site's agent', misfiled in the vaults section where nobody looking for a method would find "
     "it, the same misfiling that created case-studies/ in v0.1.14. It stays at its URL and is "
     "listed from both places. NAV: Briefs moves out of the Evidence group and into Docs, beside "
     "Skills, the section's centre of gravity is now agent-facing documentation rather than a "
     "collaboration record, and Docs is where an agent looks. Evidence keeps comparisons, case "
     "studies and use cases. ONE EDITORIAL CALL WORTH RECORDING: the source brief opened by "
     "answering 'is there a doctor/patient case study for this?'. There is not; the health-score "
     "vault is one vault with three audiences, not two vaults messaging. In the repo brief that "
     "section earned its place because the question was asked. On a published page the reader "
     "never asked it, and listing that vault among the pages to read implies it is a source for "
     "cross-vault messaging, which would send the next reader somewhere useless. Cut from the "
     "reading list, kept as one line of signposting, because the health-score vault is exactly "
     "where somebody will go looking, and a named absence beats a hidden one."),
    ('v0.2.54', '2026-09-05', 'obj-cas-imm-f56c7c51361e',
     "THE AIUC-1 VAULT, FORKED AND LAYERED, AND TWO PAGES FOR TWO ARTEFACTS, ONE ROW ON THE "
     "LIST. Vault 2wzct4k7 arrived as an updated version of the AIUC-1 catalogue (hq21tlqu) and "
     "the first instinct was to replace the page. Reading its own CONFORMANCE.md changed the "
     "shape: it copies every byte of the catalogue and adds one directory above it, answering a "
     "DIFFERENT question, not 'what does the standard say' (evidenced_by) but 'does this "
     "subject do it' (attested_by), and then what would be insurable on a given date. Two "
     "artefacts, not two versions. So the catalogue page stays exactly as it was, carrying a "
     "note that names the fork; the fork gets its own page; and the vaults table carries ONE row "
     ", the fork, whose description links back to the catalogue page, because a table of "
     "published vaults is a list of things to open, not a changelog. VERIFIED RATHER THAN "
     "REPEATED: the fork's claim is that it did not edit what it copied, so before publishing we "
     "ran both suites in the clone, the catalogue's tests/run.py reports 21/21 passed, "
     "including the one that rebuilds every source document to the word, and the layer's "
     "tests/test_conformance.py reports 19 tests, 0 failed. Neither opens the network. That is "
     "the difference between quoting a vault's self-description and checking it. Audit clean: no "
     "vault keys, no third-party secrets, no personal data; the two sgit read keys inside it are "
     "both already published here by design (regulation graph, Risk Graph Explorer). Four "
     "screenshots captured against a local clone rather than the live host. One correction to a "
     "claim we nearly made: this is NOT the first vault here to ask for a permission, the "
     "VoiceDebrief pitch and Licence to Operate got there first, but it IS the first to ask for "
     "a WRITE grant (fs.write scoped to chat/ and nothing else) and the first to ask for the "
     "sg.llm bridge, which is the more interesting claim anyway."),
    ('v0.2.53', '2026-08-27', 'obj-cas-imm-d889737f4500',
     "ONE OPEN BUTTON PER VAULT PAGE, NOT THREE. Reported from a phone, which is where it "
     "actually hurt: the three pages carrying the open-in-a-new-tab button (AIUC-1, VoiceDebrief "
     "pitch, Licence to Operate) placed one under the lead, one above the embed and one at the "
     "foot. On a narrow viewport the first two sat within a single scroll, separated only by the "
     "read-key note, two identical full-width buttons doing the same thing, which reads as a "
     "layout bug rather than an offer. Reduced to ONE per page, positioned immediately above the "
     "embed, because that is the only point where the reader is genuinely choosing between the "
     "frame and a tab; the caption there now carries the REASON that had been sitting on the "
     "button removed above it ('a full application', 'a presenter app', 'an interactive "
     "simulation'), so nothing was lost by deleting two thirds of the buttons. The early exit is "
     "still available and always was: all 24 vault pages carry 'open it read-only in a new tab' "
     "as a text link inside the read-key note, which is what made the button under the lead "
     "redundant twice over. Verified at 390x844 with a mobile user agent rather than by "
     "eyeballing a desktop render: one button per page, no horizontal overflow, text link "
     "present. The general lesson, since this will recur as more vaults arrive: repeating a "
     "call to action is only helpful when the repetitions are far enough apart that a reader has "
     "forgotten the first one, at one scroll's distance it is just clutter."),
    ('v0.2.52', '2026-08-27', 'obj-cas-imm-618e386c76b0',
     "LICENCE TO OPERATE, an insurance policy for an agent, simulated, and the 24th published "
     "vault. THE IDEA WORTH STEALING IS THE DELTA: the grant is what the agent CAN do (12 "
     "capabilities), the mandate is what the user expects and the only thing the policy insures "
     "(4, crm:read, kb:search, llm:generate, mail:draft), and the delta is the 8 that sit inside "
     "the agent's reach and outside its authority with no policy covering them, including "
     "crm:write, crm:export, mail:send and shell:exec. The mandate is 'answer a customer's "
     "question from their own record and the help centre, and draft, never send, a reply'; the "
     "grant includes shell:exec. That is nhi.sgit.ai's blast-radius argument made COUNTABLE, and "
     "priced rather than described. The simulation makes a reader spend it: three replies per "
     "turn, each showing its cost before commitment (in band, claims against the pool, or "
     "outside cover) over a live rate table with a normal band, an ask-above threshold, an "
     "untouchable reserve and customer records marked uninsurable above 20. ARCHITECTURE PROVED "
     "BY THE GRANT: the vault holds the terms and the browser holds the run, and app.json "
     "requests fs.read plus downloads (READ, NO WRITE, AT ANY PATH) so an app that simulates "
     "spending against a policy is structurally incapable of editing the policy it spends "
     "against. INTAKE: the credential submitted was a VAULT KEY in the legacy passphrase form, "
     "not the read key the handover was written for, the classifier refused it, the read key was "
     "derived, and only that is published. PROCESS NOTE WORTH KEEPING: the authoring agent "
     "supplied its own audit of all 16 commits, and it was accurate, but it was CHECKED rather "
     "than accepted. Three claims re-verified against a fresh read-key clone: no credentials or "
     "third-party secrets (confirmed), the /home/claude/ build paths baked into the PDF are gone "
     "(confirmed, zero occurrences, fixed upstream in v0.3.1), and the single full-length "
     "credential in the vault is the vault's OWN read key, established by deriving it "
     "independently and matching it byte for byte rather than by trusting the label on it. That "
     "check briefly looked like a finding, which is the argument for running it: a supplied audit "
     "is evidence, not a substitute for the check."),
    ('v0.2.51', '2026-08-27', 'obj-cas-imm-a13b6654c800',
     "A PITCH DELIVERED FROM A VAULT, and the first non-empty permission grant on the site. The "
     "VoiceDebrief pitch to Founder Institute (2 Sep 2026) is the 23rd published vault and the "
     "first that is a live presentation rather than a document set: opening it launches a "
     "presenter app with twelve timed slides, a 3:00 countdown, speaker notes, Focus and "
     "Fullscreen, five backup Q&A slides, one of which is titled 'WhatsApp / ChatGPT already "
     "does this', the obvious objection answered rather than avoided, and a Materials view over "
     "everything else. What makes it a vault rather than an export is that the WORKING ships with "
     "the conclusion: the approved outline and its claims-to-keep-exact list, the spoken script "
     "per slide with timings, the fifteen-part pitch pack, the research notes, the product "
     "screenshots and the PDF/PPTX exports, with the deck itself generated from "
     "deck/src/deck.template.html by a script in the vault rather than hand-maintained. THE "
     "PERMISSION STORY IS THE REASON IT EARNS A PAGE: every other vault here declares "
     "permissions {}, and this one declares downloads:true and externalLinks:true, it offers PDF "
     "and PPTX buttons so it asks for downloads, it links to the live product so it asks for "
     "external links, and that is the entire request, with NO FILESYSTEM ACCESS AT ALL at any "
     "path. One readable line that maps one-to-one onto two visible affordances is the permission "
     "model working as intended, and it reads better against the Risk Graph Explorer's empty "
     "grant than any amount of explanation. AUDIT: credentials and third-party secrets clean. "
     "Three disclosures are named on the page rather than left to be found, since a fundraising "
     "deck is a different category from the rest of the estate, the unit economics and "
     "commercial terms, the author's contact address on the closing slide, and the THREE NAMED "
     "JUDGES of the session with their affiliations. That last one was checked before publishing "
     "rather than after: the sources were read for tactical notes about the named individuals and "
     "contain none, only names, roles and affiliations, which is the difference between "
     "publishing a fact about a public event and publishing research about people."),
    ('v0.2.50', '2026-08-27', 'obj-cas-imm-2de78eaae20b',
     "OPEN THE VAULT IN A NEW TAB, and a path that sent a reader to a 404. The AIUC-1 catalog is a "
     "full application and has far more room in its own tab than in the embedded frame, so the "
     "page now carries a prominent button at the three points where a reader is actually deciding "
     "whether to leave: under the lead, at the embed, and at the end. All three open in a new tab "
     "with rel=noopener, verified in a browser rather than assumed. THE 404: the page referred to "
     "`NOTICE.md` and `docs/source-policy.md` as bare filenames, and a reader reasonably went "
     "looking for them in a GitHub repository, where they do not exist, checked, the CLI repo has "
     "no docs/ directory at all and zero matches for source-policy. Those files live INSIDE THE "
     "VAULT and nowhere else, which is the whole point of the vault, and the page now says so at "
     "the point of reference. Worth recording because the site itself was not at fault: sgit.ai "
     "renders both as plain <code>, never as links, in the HTML and in the .md twin, the failure "
     "was that a filename with no stated home invites a reader to guess one, and a guess against a "
     "repository is the obvious guess. Naming the container is part of naming the file."),
    ('v0.2.49', '2026-08-27', 'obj-cas-imm-133059705000',
     "AIUC-1 AS A CITABLE GRAPH, an unofficial, derivative machine-readable catalog of the public "
     "AIUC-1 agent standard, and the twenty-second published vault. 53 controls, 144 requirements, "
     "1,126 crosswalks to 13 external frameworks, 1,238 nodes and 3,526 edges over five releases, "
     "with every field naming the official page or commit it was read from, the SHA-256 of the "
     "retrieved bytes and the retrieval time. A control renders as its EDGES rather than a "
     "property bag (has_requirement, maps_to, evidenced_by, applies_to_capability) which is the "
     "graphs.sgit.ai grammar applied to a compliance standard. THE BEST THING IN IT IS A REFUSAL: "
     "it publishes the five places where the official website and the official changelog "
     "repository disagree, classifies each as presentation rather than meaning, and declines to "
     "resolve them, 'resolving one means choosing a source, and that is not this build's to "
     "choose'. A derived artefact that silently picks a winner has stopped being derived and "
     "become an opinion the reader cannot detect. Also: a release AIUC names but which carries no "
     "commit is recorded as UNBUILT rather than dropped, and 194 derived change events are kept "
     "separate from the 104 change rows AIUC publishes itself. Collection policy is explicit and "
     "worth copying, identifying user agent, one request per second, no auth, no slug guessing, "
     "every page reached from a page already fetched, and robots.txt fetched first (404 at capture "
     "time, with the manifest recording that observation verbatim rather than the conclusion "
     "alone). INTAKE NOTE: the credential arrived as a sgit_private_read_ READ key inside a vault "
     "URL, the first submission in that form since the classifier learned the prefix, and it "
     "classified correctly as publishable rather than coming back 'unrecognised'. Nothing had to "
     "be derived. THE HOLD THAT WAS RAISED AND THEN CLEARED BY THE AUTHOR: the vault's own "
     "NOTICE.md and docs/source-policy.md state that reuse rights for the full AIUC-1 control text "
     "have NOT been confirmed with AIUC and that anyone republishing publicly should confirm them "
     "first. Publishing a read key is exactly that republication, so the work stopped and asked "
     "rather than proceeding past a warning the vault carries about itself; the author chose to "
     "publish as-is. The page therefore reproduces the vault's disclaimers in full above the fold "
     "instead of summarising them away, and repeats its removal undertaking. Audit otherwise "
     "clean: no sgit credentials, no third-party API keys, no private keys; permissions {}."),
    ('v0.2.48', '2026-08-27', 'obj-cas-imm-2d922c5fe406',
     "BOTH PRESENTATION VAULTS NOW LINK THEIR PUBLIC SOURCE. Confirmed by the author that "
     "the-cyber-boardroom/Presentation__BlackHat-EU__Dec-2025 and "
     "DinisCruz/Presentation-Threat-Mod-Con-2025 are public repositories. This settles a question "
     "left open when the Black Hat vault shipped: the branding note was written without being "
     "able to establish whether the material was already in the open, because github.com returns "
     "403 to this container's proxy for any repo outside the session's scope, the same 403 for "
     "both repos, which is a proxy behaviour and not a signal about either. The consequence is "
     "worth stating precisely: the vault is a SECOND copy of material that already sits in public, "
     "not the thing that first exposes it, and the Black Hat speaker-template assets in "
     "particular are therefore not published here for the first time. The branding note itself is "
     "unchanged and still stands, the deck uses the official template because it is a talk that "
     "was given there, published as the speaker's own material and not as anything endorsed by or "
     "affiliated with the conference. On the ThreatModCon page the link earns its place for a "
     "different reason: that vault repairs two upstream files that are invalid JSON, and a repair "
     "expressed as a proof (four stray brackets removed, one `],` added, multiset of content lines "
     "unchanged) is only auditable if a reader can fetch the broken originals, so the link sits "
     "in the repair section rather than in a footer. Both URLs were taken verbatim from each "
     "vault's own README rather than retyped."),
    ('v0.2.47', '2026-08-27', 'obj-cas-imm-dac438a3ef81',
     "THE DIRECTORY ANSWERS QUESTIONS, and a second conference vault. Nineteen sibling sites is "
     "past the point where a list helps, so /network/ now carries a chat panel whose only job is "
     "routing, which of these is mine. THE DEFAULT TIER NEEDS NO KEY, NO ACCOUNT AND NO NETWORK "
     "CALL, a deterministic scorer running in the browser over a catalogue emitted at build time "
     "from admin/content/sites/*.md, the same data the cards and the table are built from, so the "
     "answers cannot drift from the directory underneath them. It also SHOWS ITS WORKING: a hit in "
     "a site's thesis or domain outweighs one in its summary, and the reply names the matched "
     "terms, which an LLM answer does not give you. Tier 1 is opt-in BYOK against OpenRouter, "
     "streaming, reusing the pattern already proven in the SG/Vault workbench vault (same "
     "endpoint, same versioned sg-llm-request module on dev.tools.sgraph.ai) rather than "
     "inventing a client; on failure it falls back to the local matcher and says so. The cost is "
     "stated on the panel, not buried: with no host there is no permission floor, so the key sits "
     "in the page's origin, never sent to sgit.ai, which has no server to send it to. Tier 2 (the "
     "vault-app build, where sg.llm.chat keeps the credential in .vault/llm/config.json below the "
     "permission floor) is NOT BUILT and is scoped in a new article including the rows that are "
     "not started. ONE REAL MISS FIXED: the matcher routed 'I need to cite a regulation precisely' "
     "to wardley-maps, because it knew only each site's own vocabulary, standards.sgit.ai says "
     "'provision' and the reader types 'regulation'. Site entries gained an `aliases` field for the "
     "words readers actually arrive with; five real questions now give five correct first hits, and "
     "nonsense still returns nothing rather than a confident wrong answer. TWO BUILD RULES CAUGHT "
     "ME on the way, both correctly: the validator refused a forward link to the unwritten plan "
     "article, and then refused a script tag with a src attribute outright, these pages must also render inside a "
     "vault on a blob: origin where a relative src does not resolve, so the loader is fetch+eval "
     "like every other component here. ALSO PUBLISHED: ThreatModCon 2025 Barcelona, 'Scaling "
     "Threat Modeling with Semantic Knowledge Graphs', eleven linked threat models from customer "
     "to compute instance (51 nodes, 179 threats, 3 critical), five interactive views and five "
     "Wardley walkthroughs, libraries inlined at identical versions so no page touches the "
     "network. Two of its layer files are INVALID JSON UPSTREAM and are repaired in the vault, "
     "with the repair expressed as a proof rather than a changelog entry: four stray closing "
     "brackets removed, one `],` added, multiset of content lines unchanged, originals kept "
     "alongside. Its explorer screenshot was CAPTURED AND THEN DISCARDED, outside the vault host "
     "there is no sg.vfs.readText() to answer it, so the page sits on a loading spinner, and a "
     "screenshot of a spinner would have misrepresented the vault."),
    ('v0.2.46', '2026-08-26', 'obj-cas-imm-057b0451154a',
     "A CONFERENCE KEYNOTE AS A VAULT, the twentieth published vault and the first that is a "
     "TALK rather than a document set or an app. AI vs. AI: Building Resilient Enterprises in the "
     "Age of Autonomous Threats, Black Hat Europe 2025, AI Security Summit, ExCeL London, 9 "
     "December 2025. What makes it a good demonstration is that the whole chain travels together "
     "under one credential: the deck as presented (26 slides, ~976 KB self-contained app), six "
     "PDF exports v0.1.1 to v0.2.0, the eight research papers the talk was built from, and the "
     "slide system's own source at ten versions v0.1.0 to v0.1.9. A deck emailed as a PDF is a "
     "snapshot with its working removed; this is the working. The load-bearing design detail is "
     "that SLIDE CONTENT IS DATA: deck/blackhat-eu-2025.json is read through the vault bridge at "
     "load time, so editing a slide is a commit and needs no rebuild, which is also why ten "
     "renderer versions can sit beside one deck without either owning the other. permissions {} "
     "with present:true. INTAKE: the submitted credential carried the sgit_private_vault_ prefix "
     "and was refused as a WRITE credential by the classifier, the prefix family added in the "
     "v0.2.40 batch, working as intended on its first real submission since. Read key derived "
     "one-way, vault key to the gitignored tier. AUDIT clean across all four passes: no sgit "
     "credentials, no third-party API keys (the broadened sweep added after the OpenRouter miss), "
     "no private keys, no emails, no external company or client. AWS, Azure, Cloudflare and "
     "CrowdStrike appear cited for publicly documented outages, which is the subject of the "
     "slide. One judgement recorded rather than buried: the deck uses Black Hat Europe's OFFICIAL "
     "SPEAKER TEMPLATE, logo and trademark included, because it is a talk that was given there, "
     "published as the speaker's own material with the page stating plainly that it is not "
     "endorsed by or affiliated with the conference. Screenshots were captured by serving the "
     "cloned vault locally and driving the deck with its own arrow-key bindings, after keypresses "
     "sent to the hosted surface failed to reach the deck through the shadow DOM; the first three "
     "captures were also named one slide out of step with what they showed and were renamed to "
     "match rather than shipped with captions that did not describe the picture."),
    ('v0.2.45', '2026-08-26', 'obj-cas-imm-598b21e85f91',
     "ARTICLES GET A HOMEPAGE BAND, A NEW ARTICLE ON THE SPLIT, AND A LAYOUT BUG FIXED FROM "
     "YESTERDAY. The network band shipped in v0.2.44 ran the FULL VIEWPORT WIDTH: the homepage "
     "bands each carry their own measure on the component (.eco is max-width:1100px itself, there "
     "is no wrapper convention here) and the new band had none, so it stretched edge to edge on a "
     "wide screen while every other band stayed in the column. Reported from a screenshot rather "
     "than caught by the validator, which has no opinion about layout. Fixed and MEASURED: .eco, "
     ".netpick and the new .artcards all report exactly 1100px at a 1440px viewport with "
     "scrollWidth == innerWidth, and the five area cards now lay out 3+2 instead of 4+1 so the "
     "last card does not sit alone. NEW ARTICLE, 'Twenty sites in fifteen days, and what that did "
     "to the writing': the repositories were created between 11 and 26 August, fifteen of the "
     "twenty in the final five days, and the piece is about what that did to the writing rather "
     "than the count, what forced the split (an argument needs its own version history; nineteen "
     "arguments through one changelog produces a record nobody can read), what it cost (discovery "
     "got worse before it got better; consistency became discipline rather than construction; "
     "cross-site links became claims that can rot, as the sentinel.sgit.ai typo showed), and why "
     "the directory now opens with a question. ARTICLES BAND on the homepage, DERIVED from the "
     "articles list via an <!--ARTICLES--> marker the build fills, a new article appears there "
     "by being written, no list to maintain, the same one-file rule as updates and sites. Also: "
     "influences.sgit.ai went live mid-session and is now a full entry with its hero screenshot, "
     "taking the family to 18 live of 19; skills.sgit.ai still has DNS and a repository with "
     "nothing published and is still listed as such."),
    ('v0.2.44', '2026-08-26', 'obj-cas-imm-d629c8d66421',
     "THE NETWORK BECOMES A DIRECTORY. The sibling-site section was built for four entries "
     "and there are now NINETEEN, 17 live, 2 with repository and DNS in place but GitHub Pages "
     "not yet published (skills, influences), enumerated from the SGit-AI org and probed one by "
     "one for DNS, HTTP status and their own stated thesis. At four, a list of cards was the "
     "right shape; at nineteen it is a directory a reader has to read before it helps them. So "
     "the page now LEADS WITH THE QUESTION: seventeen lines, each one something somebody "
     "actually arrives with ('I need to give an AI agent an identity', 'I have to sign off a risk "
     "and I do not want to rubber-stamp it', 'I want an issue tracker with no database'), mapped "
     "to the site that takes it seriously. Under it, five thematic groups (Agents & AI, Risk & "
     "governance, Graphs & method, Security & infrastructure, Business & publishing) then a full "
     "scannable table of all nineteen. Every thesis is the SITE'S OWN WORDS, quoted from its H1 "
     "or lede rather than paraphrased here, so an entry cannot drift into describing a site that "
     "no longer says that. Engine: the sites content type gained `listing: true` (a directory row "
     "with no page of its own), plus `category`, `stage` and `thesis`; the four sites with a full "
     "write-up keep their pages and the other fifteen are one short markdown file each. Network "
     "promoted from the third child of Updates to a top-level nav group, and the homepage gained "
     "a network band with five doors in by area, a reader who does not know these sites cannot "
     "pick one from a list of domains. Thirteen new hero screenshots captured from the live sites "
     "through the local mirror. The two unpublished sites are listed AS unpublished, linking their "
     "repositories, rather than omitted. One engine bug found and fixed on the way: the first "
     "attempt to add the new fields landed in load_articles instead of load_sites, because the "
     "`'status': ...` + `'body': body` pair appears in both loaders and the articles one matched "
     "first, caught immediately by a KeyError at build, which is the argument for the build "
     "failing loudly rather than defaulting a missing field."),
    ('v0.2.43', '2026-08-25', 'obj-cas-imm-48fd5df1d329',
     "SIX VAULTS PUBLISHED, THREE HELD, and one audit gap found the hard way. Nine credentials "
     "were submitted; intake classified seven as WRITE keys and two as read keys, the seven read "
     "keys were derived one-way, and all nine were cloned and audited with the credential that "
     "would actually appear on the page. New pages: PENETRATION TEST REPORT (a pentest as a vault "
     "not a PDF, eight audience-specific views over one engagement, and a retest script per "
     "finding that exits 0 if fixed and 1 if not; entirely fictional and badged SIMULATED DEMO on "
     "its own front page), STANDARDS ATLAS GDPR ('the standard is the graph', rulings, guidance "
     "and per-country variation as first-class nodes, corrections scoped to feedback/ so a "
     "reviewer can never alter the graph under review), RISKMANDATE FILE SECURITY (risk "
     "acceptance moved from end-of-flow rubber stamp to the centre, over versioned JSON queried "
     "live by SQLite in the browser), CONTENT-TRANSFORMATION PROXY, SG COMMERCIALISATION (whose "
     "most credible artefact is an engagement register with no rows) and the SG/PAYMENTS BRIEF "
     "PACK (ten documents stamped PROPOSED). HELD: one vault carrying two live vault keys in "
     "plaintext INCLUDING ITS OWN WRITE KEY (publishing its read key would have handed out write "
     "access, defeating the entire read/write split) and one private working log. THE THIRD HOLD "
     "IS THE FINDING: a vault whose app reads an LLM key from a file passed the first credential "
     "pass CLEAN, and was caught only because a screenshot of it showed a chip reading 'key: vault "
     "key.json'. The file held a live OpenRouter API key. The scan had looked for vault-key "
     "shapes, sgit_ prefixes, PRIVATE KEY blocks and the literal string api_key; the field was "
     "named openrouter_key and matched none of them. This is a genuine gap in the tooling, not a "
     "near miss to be reframed, the credential checks were built for sgit credentials and had no "
     "opinion about third-party API keys, which leak just as expensively. A broader sweep "
     "(OpenAI/Anthropic/GitHub/AWS/Google/Slack/JWT shapes, placeholders filtered) now runs over "
     "every candidate; across all nine it found exactly one further hit, a forged alg:none token "
     "in the pentest vault that IS the finding it documents. Content checks answered two specific "
     "questions asked at submission: the commercialisation vault holds no real data (empty "
     "register, no emails, no rates, and its 'team' files are agent role definitions, not people), "
     "and the proxy vault names no external company or project, verified in text and BY EYE "
     "across 120 slide and diagram images a text scan cannot read. The GDPR atlas does name real "
     "companies, and correctly so: they are published CJEU and FTC case records. Article updated: "
     "nineteen vaults, 1,389 files re-cloned and verified, plus the LinkedIn republication link."),
    ('v0.2.42', '2026-08-25', 'obj-cas-imm-75af1686df3e',
     "AN INTRODUCTION ARTICLE, and the first CLI imagery on the site. /articles/what-sgit-is.html "
     "is the piece to hand somebody who has never heard of this: the category of file that has "
     "nowhere good to live, the vault key as address+auth+encryption in one string, the one-way "
     "read key that makes sharing possible, and then the proof rather than the claim. Two new "
     "terminal screenshots carry that proof and are the first of their kind here, every one of "
     "the 73 existing screenshots was of a browser. Both were captured from real runs, not "
     "mocked: a clone of the published EU AI Act vault (273 objects, 207 files) and the same "
     "vault's stored object rendered as a hexdump, 786 bytes of AES-256-GCM ciphertext whose "
     "name is a SHA-256 of those encrypted bytes. Also written up: apps in vaults running under "
     "permissions {}, capability without credential, and the four-site network. VERIFIED "
     "BEFORE PUBLISHING: all eleven published read keys were cloned from scratch, 781 files "
     "total, zero failures. That check first reported the opposite. Every clone failed with 'no "
     "named ref on the server', which looked like every vault page on this site instructing "
     "readers to run a command that could not work, and the real cause was a stale CLI in the "
     "authoring container, v0.15.0, where sgit clone cannot reach these vaults. v0.16.0 clones "
     "all eleven. The finding was retracted rather than shipped, which is the whole reason the "
     "check exists: an intro article is exactly the page that must not repeat an instruction "
     "nobody re-ran. A LinkedIn-newsletter edition of the same piece was produced alongside it "
     "as plain text with image placement markers, since that editor renders no markdown and "
     "asterisks would paste literally."),
    ('v0.2.41', '2026-08-22', 'obj-cas-imm-f401ef7115e0',
     "GRAPHS.SGIT.AI RESOLVES, the CNAME landed hours after v0.2.39 shipped the entry, so the "
     "`url:` override comes back out and the card links the subdomain directly. Deleting one "
     "frontmatter line was the entire change, which was the point of adding the field: a site "
     "finished before its DNS should be linked at the address that answers, and should not need "
     "rewriting when the real one starts working. The DNS-pending chip and the note both "
     "disappear on their own because they were derived from url != domain rather than written "
     "into the page. Verified before and after: graphs.sgit.ai returned nothing from a resolver "
     "on 21 August and answers 200 today. The sentinel.sgit.ai correction on that entry was "
     "re-checked rather than carried forward on trust and still stands, the graphs site links "
     "to a host that does not resolve, and the site is sg-sentinel.sgit.ai. Also settled this "
     "release, for the record: the 34 historical tags CI could never push (v0.1.9-v0.2.16, "
     "blocked because GITHUB_TOKEN cannot push a ref at a commit carrying a different workflow "
     "blob) are now on the remote, pushed from a workflows-scoped credential. The first attempt "
     "was a plain `git push --tags`, which reported 'Everything up-to-date' and did nothing: the "
     "tags only ever existed inside destroyed CI runners, so no clone had them to push. They had "
     "to be recreated locally from the commit subjects first. All 58 release commits are now "
     "tagged, each verified to point at the commit whose subject names it, and the backfill loop "
     "is a no-op from here."),
    ('v0.2.40', '2026-08-22', 'obj-cas-imm-550fd7d8bac2',
     "THE TAG GATE TOOK THE SITE DOWN, and this release fixes the gate and publishes what it "
     "held back. Two good commits landed on dev after v0.2.39, the VoiceDebrief vault (the ninth "
     "published vault, ten screenshots driven from its published read key) and a check_credential "
     "fix, and sgit.ai served neither for a day. Nothing was wrong with either commit. The CI "
     "tag job read SITE_VERSION, found v0.2.39 already tagged on the EARLIER commit, and failed "
     "with 'SITE_VERSION was not bumped for this release', when the truth was that this was not "
     "a release at all. Because the deploy job needs tag-release to be success OR skipped, and a "
     "FAILURE is neither, the publish never ran. Re-running could not help: the check is "
     "deterministic, and attempt 2 failed identically. THE FIX: what makes a push a release is "
     "now its commit subject, which is where release.sh already writes the version. A commit "
     "carrying no 'site vR.M.N:' subject is an ordinary push, tagged nothing, published anyway. "
     "A commit that DOES claim a version is held to the full contract, and to a stricter one than "
     "before: the subject and SITE_VERSION must agree (previously only inferred, via whether the "
     "backfill loop had produced the tag), the version must not already have shipped, and it must "
     "be the next minor. The reasoning behind the asymmetry is recorded in the workflow: a "
     "missing tag is a bookkeeping gap, a blocked deploy is an outage. Verified before shipping "
     "by extracting the job's script and running it over five cases in a throwaway clone, the "
     "exact commit that failed today now exits 0; a proper release tags; subject/SITE_VERSION "
     "disagreement, a reused version and a skipped minor all still fail, each with a message that "
     "names what is actually wrong. SECOND CONSEQUENCE, less visible and worth recording: those "
     "commits went to git only, so the vault remote never received them and the two stores had "
     "drifted, sgit status showed all fifteen VoiceDebrief files as uncommitted. That is the "
     "invariant release.sh exists to hold and the reason CI does not author commits itself. This "
     "release carries them across. Also in: the VoiceDebrief vault page goes live, "
     "check_credential now recognises sgit_private_vault_/sgit_private_read_ (the prefixes the "
     "CLI actually prints on init and clone, where the classifier previously called a good read "
     "key 'unrecognised', fail-closed, but the kind of refusal that tempts an operator to reach "
     "for the vault key instead), and validate.js skips node_modules."),
    ('v0.2.39', '2026-08-21', 'obj-cas-imm-3d8cf0d26b96',
     "Fourth site in the network: GRAPHS.SGIT.AI, a grammar for semantic graphs argued in "
     "increasing depth, five rules you can apply tomorrow, a working edge set with numbered "
     "gaps, then a full positioning against schemas and vector search. Its opening move is to "
     "disown the category a reader arrives expecting: 'this is not a graph database pitch… there "
     "is no graph database anywhere in the work behind this site'. The thesis is that two nodes "
     "both holding 8080 differ not in the value but in the connectivity, and the strongest "
     "argument on the site needs no technical background at all: 10,000 hours was an AVERAGE in "
     "a 1993 violinist study, not a threshold, half the top group had not reached it, and the "
     "author spent his career correcting the popularisation, but the correction never attached, "
     "because by then the claim had been carried through 242 papers and 200,000+ citation paths. "
     "A document cannot fix that; a graph can mark a claim superseded and turn 'what did we build "
     "on this?' into a query. Grammar highlights: every edge is a verb with a distinct inverse, "
     "the test being 'would a person in this business say this sentence?', and relates-to is "
     "BANNED for a mechanical reason, an edge with no verb carries no constraint, so it cannot "
     "narrow a traversal; it costs fan-out and buys nothing. Relevant here because the one "
     "indisputably shipped item on that site is sgit's own object model: a content-addressed "
     "commit DAG (SHA-256 over CIPHERTEXT, multi-parent, wave-BFS merge-base) plus *.link.json "
     "commit-pinned cross-vault edges and the sg.history read-only query API exposed to untrusted "
     "sandboxed apps. The grammar argument and the vault are not neighbours by theme; they share "
     "a data structure. It is also the FIRST SIBLING WITH A RECIPROCAL LINK, an '↗ part of "
     "sgit.ai' chip in its nav and a footer pointing back at /network/, so the index stops being "
     "one-way. Engine change: a site entry may now carry a `url:` that differs from its `domain:`. "
     "graphs.sgit.ai does not resolve yet (verified: no DNS), and a finished site should be linked "
     "at the address that answers rather than held back for a CNAME, so the page states which one "
     "it is instead of shipping a dead link and needing a rewrite later. The network audit also "
     "found a broken link on the new site itself: it points at sentinel.sgit.ai, which does not "
     "resolve. The site is sg-sentinel.sgit.ai. Recorded on the entry for upstream."),
    ('v0.2.38', '2026-08-20', 'obj-cas-imm-b270f3424691',
     "Third site in the network: SG-SENTINEL.SGIT.AI, a design for an app-coupled edge guard "
     "replacing rented AWS WAF + CloudWatch/Firehose with a layer you own. Its central inversion "
     "is that a generic WAF is blind to the app it protects and must denylist, whereas an edge "
     "that knows the valid request space can ALLOWLIST, no invalid request reaches the origin. "
     "Two things make it worth a page rather than a link. First, a governing correction stated as "
     "a constraint: 'Layer 1 never acts and never writes, it only decides and signals; Layer 2 is "
     "the sole actor and the sole I/O owner', because a CloudFront Function physically has no "
     "network and no filesystem, and the site names its own earlier design, where L1 blocked "
     "inline, as a category error. Second, rules are the engine rather than configuration on it: "
     "six deterministic rules, each a pure function mapped to an ATT&CK technique, run "
     "first-block-wins, with a prototype exercise running the same engine across three targets "
     "under a parity matrix asserting identical decisions. It also carries the most honest status "
     "language on the network. The pill reads NOT BUILT, and where the prototype's 149 passing "
     "tests are cited the site immediately bounds them as 'not deployed anywhere, not in "
     "production use, not maintained, not packaged for you to install'. Adding it cost one "
     "markdown file and three screenshots, which is what the content type was built for. One "
     "engine gap surfaced doing it: the markdown renderer had no TABLE support, so the six-rule "
     "core rendered as a paragraph of vertical bars. Pipe tables now parse (header, separator, "
     "body) and emit into the site's own .tablewrap, so they scroll on a phone and survive print "
     "like every other table here. Caught by looking at the output rather than trusting the "
     "validator, which had nothing to object to: badly rendered markdown is still valid HTML."),
    ('v0.2.37', '2026-08-20', 'obj-cas-imm-1b229ee71709',
     "REGULATION GRAPH, the EU AI Act as a citable graph, and the first vault here whose "
     "publication the audit STOPPED rather than cleared. Regulation (EU) 2024/1689 parsed from "
     "official Formex XML retrieved from CELLAR, hash-verified to the source bytes: 113 articles, "
     "500 paragraphs, 180 recitals, 68 definitions, resolving to 1,523 nodes and 1,944 edges "
     "across eleven views, browse, Cytoscape citation graph, in-browser SQLite over sql.js, RDF "
     "via rdflib, concepts, external instruments, and an experimental Art 9 lab as the declared "
     "entry point. WHAT HAPPENED: the submitted credential was a VAULT KEY, not a read key, the "
     "fourth time, caught by the intake check. The read key was derived and the audit run with "
     "it across 204 text files, which is when it found a LIVE VAULT KEY IN PLAINTEXT inside a "
     "handoff document, granting write access to a DIFFERENT vault. Publishing our read key would "
     "have handed that away. No page was written; the finding was reported first. Deleting the "
     "file would not have been enough, because vault objects are content-addressed and immutable, "
     "so a credential committed once may stay reachable from history, the only clean remedy is "
     "history that never held it. So this is a REDACTED REPUBLICATION into a new vault: same 206 "
     "files, two credentials replaced in place with visible <VAULT-KEY-REMOVED> and "
     "<READ-KEY-REMOVED> markers rather than silent deletions, a PUBLIC.md stating the rules and "
     "the removals, and a re-audit from a fresh read-key clone: 205 files, zero findings. The "
     "second removal was a judgement call, a read key is publishable by its OWNER, and that one "
     "belonged to a third vault, so it goes and the decision stays with a human. RULE 3 VERIFIED "
     "RATHER THAN ASSUMED: the Graph REPL is an LLM chat, and repl.js looks for an OpenRouter key "
     "at /key.json INSIDE THE VAULT before falling back to device storage, so a shipped key would "
     "be an open tab on somebody else's budget. No key.json exists, confirmed in the read-key "
     "clone rather than in our working copy. The 370-plus bare 64-hex strings the scan flagged "
     "were all sha256 provenance hashes, checked individually, a scan that never produces a "
     "false positive is not scanning hard enough. The rule that caught all of this came from Risk "
     "Graph Explorer's own PUBLIC.md, not from us; it has now paid for itself."),
    ('v0.2.36', '2026-08-19', 'obj-cas-imm-18bcdab6db5d',
     "A NETWORK section for the sibling *.sgit.ai sites, built as a content type rather than as "
     "two pages, because many more are coming. nhi.sgit.ai argues that 'how do I give my agents "
     "an identity' splits into agents you RUN and agents you RENT, and that the industry answers "
     "only the first, SPIFFE for workloads you can attest, an open feature request for the "
     "agents anyone actually names. pki.sgit.ai designs a key registry from the 2019 keyserver "
     "failure and publishes four rules BEFORE the registry exists, reaching a resolution worth "
     "borrowing: append-only is safe when a writer appends only to objects it owns and fatal "
     "when anyone may append to somebody else's, so the rule to carry is not 'append-only' but "
     "'the writer owns what it writes', which is append lanes, already shipped. Six screenshots "
     "captured from the live sites through the curl mirror the sandbox needs, since Chromium "
     "cannot egress here. Adding the next site is ONE markdown file plus its screenshots: the "
     "index, the cards and the per-site page are derived, same contract as updates and articles. "
     "Two engine fixes fell out of building it. The generator now WIRES THE SCREENSHOT COMPONENT "
     "AUTOMATICALLY for any page containing figures, a markdown author has no place to put a "
     "script tag, and the views page had already shipped once with figures and no loader, which "
     "errors nowhere and simply shows nothing. The first version of that check matched on "
     "'figure class=\"shot\"' and missed 'class=\"shot net-shot\"', leaving the new index in "
     "exactly the state the check existed to prevent; it now matches on data-shot=, the attribute "
     "the component actually selects on. Detect on what the consumer looks for, not on how it "
     "happened to be written."),
    ('v0.2.35', '2026-08-19', 'obj-cas-imm-1f3ce8d32a28',
     "Two new sections and the navigation restructure they forced. UPDATES and ARTICLES, both "
     "built on the VoiceDebrief journalist pipeline's central rule, which was worth adopting "
     "verbatim: PUBLISHING IS ADDING ONE FILE. No index to update, no manifest to hand-edit, no "
     "decision about where a post goes, ordering, permalinks, updates.json and feed.xml are all "
     "derived. That is not tidiness, it is the property that makes an unattended journalist agent "
     "safe: two agents publishing on the same day touch two different files and cannot conflict. "
     "Frontmatter is flat key: value with no YAML parser, the folder path must agree with the "
     "date, and slugs must be unique or the build fails. ONE DELIBERATE DIVERGENCE from their "
     "contract: they author root-relative links because their site sits at a domain root; ours "
     "must also render INSIDE A VAULT at an arbitrary mount path, where a leading slash escapes "
     "the app, so posts are still authored root-relative and the build rewrites each to a "
     "depth-relative path. Authors keep the simple rule; the vault still works. Updates are one "
     "entry per STORY rather than per release, which is the whole reason this is not the version "
     "log with a nicer stylesheet, v0.2.31 alone carried three separate stories. Five seeded "
     "from recent releases; the version log stays the complete technical record behind them. "
     "Articles carry two anti-rot rules stated on the page: never restate a fact you do not own "
     "(link to the page that does, so it cannot start lying when the fact changes), and link the "
     "test behind any testable claim. Two to open: GREEN DOES NOT MEAN LIVE on the deploy that "
     "reported success twice while serving a two-release-old page, and SEVEN VAULTS, ONE METHOD "
     "on what publishing seven vaults taught, including the three vault keys submitted as read "
     "keys. NAVIGATION: 14 flat items became 7 groups with a second level. The old bar wrapped to "
     "THREE ROWS on an iPhone before the page began, measured on a real screenshot, not guessed. "
     "Every group label is itself a link to that section's index, so nothing is reachable only by "
     "opening a menu. Desktop opens on hover and :focus-within in CSS alone; the phone collapses "
     "behind one button. The first mobile attempt made every submenu inline and measured 498px of "
     "navigation before the content, worse than what it replaced, so it was rebuilt as a "
     "collapsed menu: 91px, against 54px on desktop. Also adds an RSS feed and a JSON manifest, "
     "the first machine surfaces here aimed at a human follower rather than an agent."),
    ('v0.2.34', '2026-08-18', 'obj-cas-imm-97c957112852',
     "Acts on an inbound fix pack from the SG/API team, who audited this site against their route "
     "tables at v0.33.54 after an agent asked how to send a message between vaults and could not "
     "find the answer here. Their central finding was right: the site documented the TRANSPORT "
     "(sg.append) and the CRYPTO (sgit pki) on pages that never referenced each other, and never "
     "wrote the sentence saying they compose into vault-to-vault messaging. There was also no HTTP "
     "API reference anywhere, which is awkward for a project whose argument is that the API is the "
     "whole surface. SEVEN NEW PAGES: /docs/vault-messaging (the keystone, append lanes composed "
     "with PKI, worked end to end in CLI, curl and sg.append), /docs/pki (keypair lifecycle), and a "
     "/api/ section: index, authentication, vault-objects, append-lanes, errors. The security page "
     "gains the asymmetric layer it never had; sg.append is retitled as the message transport and "
     "cross-linked; limitations gains what PKI does NOT do; the skills page flags that the shipped "
     "agent skill still has both halves and no join. THREE OF THE PACK'S FINDINGS DID NOT SURVIVE "
     "CHECKING, which is the part worth recording. (1) It reported that the security page 'actively "
     "denies PKI'. It did not, a sweep for symmetric, asymmetric, public key, PKI and keypair "
     "returned zero occurrences. The page was SILENT, not wrong; publishing a correction for a claim "
     "we never made would have put a false statement in this log. (2) It asked us to hunt stale "
     "'inbox' naming; there is none, two hits, both ordinary English, and no /api/vault/inbox/* "
     "path anywhere. (3) It described X25519 sealing throughout. Running sgit pki keygen on v0.15.0 "
     "prints RSA-OAEP 4096 and ECDSA P-256. Publishing the draft as written would have told "
     "integrators to build against the wrong primitive. Two further corrections came from running "
     "the CLI rather than reading about it: export emits a JSON BUNDLE of two PEM blocks, not a .pem "
     "file, so the draft's sha256sum-the-pem derivation of the lane address is not well defined; and "
     "keygen requires a passphrase, which no draft step mentioned. The one genuinely unshipped step "
     ", append_token = H(public key), is labelled PROPOSED with an interim recipe rather than "
     "quietly documented as working, and the two endpoints their audit could not resolve "
     "(/api/vault/zip, /join/*) are listed as unresolved rather than described. The pack's own "
     "acceptance test, give a fresh agent only llms.txt and ask how to send an encrypted message "
     "from vault A to vault B, now passes, including the lane-address fact and its PROPOSED "
     "caveat, answered in the preamble so it survives an agent that cannot follow a link."),
    ('v0.2.33', '2026-08-17', 'obj-cas-imm-ab458fd8611d',
     "A release now ends by asking the live site what version it is serving, because "
     "'both remotes in sync' turned out not to mean 'published'. v0.2.31 and v0.2.32 both "
     "pushed cleanly, both reported success, and NEITHER reached sgit.ai: GitHub Pages "
     "failed to deploy them because codeload returned 429 (Too Many Requests) for "
     "actions/configure-pages@v5, and the deploy job died in 'Set up job' before running a "
     "single step. Validation passed, tagging passed, the push was verified against both "
     "remotes, and the site served a two-release-old page for forty minutes, which is how "
     "long it took a human on a phone to notice the version pill still said v0.2.30. The "
     "failure was invisible to every check the release ran, because it happened in a job "
     "neither remote knows about. So release.sh gained a sixth step: poll the live URL with "
     "a cache-buster until the version pill matches this release, up to eight minutes, and "
     "ABORT LOUDLY if it does not, naming the Actions page and the fact that a 429 on the "
     "action download is transient and just needs a re-run. The cost is up to eight minutes "
     "of waiting per release. The alternative, demonstrated twice in one afternoon, is "
     "telling somebody a fix is live when it is not. Same principle as the orphan-page rule "
     "and the llms.txt guard: a page nothing links to, a page the index omits, and a page "
     "the deploy never published are all equally unpublished, so the build refuses all "
     "three. Ships the content of v0.2.31 and v0.2.32, which had been written but never "
     "served."),
    ('v0.2.32', '2026-08-17', 'obj-cas-imm-7f84b9ee6727',
     "The walkthroughs page becomes a document rather than a list of links. Each of the three "
     "videos now carries the recording at the top and THE SAME SESSION READ BACK underneath it: "
     "fifteen moments, each one a timestamp that deep-links into the video, the frame the screen "
     "was showing at that moment, and an explanation of what is happening in it. The transcripts "
     "are still there, folded away at the foot of each. The reason for the format is the one "
     "thing a transcript structurally cannot do: these recordings are full of 'this guy here' and "
     "'look at this', and the words alone name neither end of what is being pointed at. Pairing "
     "each phrase with its frame is what makes the argument survive being read instead of "
     "watched. TWO SOURCES OF FRAMES, and the page says which is which. The Graph Browser "
     "moments are frames of the recording, from a narrated-review export the author produced "
     "with tools.sgraph.ai, nine timestamped stills paired with the narration spoken over each. "
     "Risk Chains and Role risk map have no such export, so their six frames were captured from "
     "the LIVE VAULT with the published read key, driven to the exact state being described, "
     "including the entry he names out loud ('you have risk 6'). What the frames turned up is "
     "most of the value, because none of it is audible: negative answers produce NAMED EDGES "
     "(never-exercised-on, never-timed-for, absent-for) rather than silence; 'no egress' draws "
     "a single assurance-coloured edge in a field of amber, so a good answer is a finding rather "
     "than the absence of one; one selection can carry two fact ids; and every risk ships with a "
     "CEASES WHEN ANY OF THESE HOLD list, its own falsification condition, cited to facts. The "
     "role dashboard also separates three arrival routes where the video describes two: held, "
     "arrives by the risk chain, and arrives by the org chart. TOOLING: the capture rig gained "
     "appProbe (ask the running app what its elements are called instead of guessing and burning "
     "a capture run per guess, it found g.cnode for chain entries and g.role for roles) and "
     "appClickMatch (substring match plus a real MouseEvent, because the graph nodes are SVG). "
     "Videos print with the player hidden and the moments intact."),
    ('v0.2.31', '2026-08-17', 'obj-cas-imm-17f0b2693daf',
     "Corrects v0.2.30 on three counts, two of them reported and one of them the reason the "
     "report was possible at all. (1) THE LAZY-LOAD BYPASS IS NOW ONLY ON PRINT. v0.2.30 "
     "prefetched every screenshot once the page went idle, which fixed printing by making "
     "every reader pay for images they never scrolled to, the wrong trade, and correctly "
     "rejected. It is now driven entirely by the print itself, and it works because of a "
     "change one layer down: on the ordinary web the loader no longer fetches bytes and "
     "builds a blob: URL, it just sets img.src. That makes each screenshot an ordinary "
     "pending document resource, which the print pipeline knows to wait for, where a "
     "fetch-and-blob is invisible to it. Cmd/Ctrl-P is caught on keydown as well as "
     "beforeprint, because the keystroke lands a few hundred milliseconds before the "
     "dialog does and that head start is what removes the race. Measured on a page that "
     "was never scrolled: 1 image loaded while reading, 9 of 9 in the PDF. The blob path "
     "remains for pages served from inside a vault, where it is the only option. "
     "(2) THE PRINT-ONLY SOURCE LINE AND LANDSCAPE HINT ARE GONE, printing these pages is "
     "an uncommon case and did not warrant instructions on the page. (3) ASSETS ARE NOW "
     "CACHE-BUSTED PER RELEASE. GitHub Pages serves them with max-age=600, and the "
     "bootstrap fetched them by bare path, so for ten minutes after every release a "
     "returning reader ran the NEW html against the OLD css and js. That is not a "
     "hypothetical: it is exactly what produced the v0.2.30 bug report, new markup whose "
     "print-only elements the cached stylesheet did not know to hide, and a cached loader "
     "without the print handler. Every fetched asset now carries ?v=<site version>; the "
     "in-vault sg.vfs path stays unversioned, since a vault lookup is by path, not URL."),
    ('v0.2.30', '2026-08-17', 'obj-cas-imm-81dc22613ddf',
     "Print and save-as-PDF, prompted by an export of the seven views page that came out wrong. "
     "TWO DEFECTS, one reported and one found while looking at it. (1) The top nav is position:sticky; "
     "Chrome paints a sticky box ONCE, wherever it happens to fall in the paginated flow, so the whole "
     "nav landed across the middle of page 2, translucent, with the prose showing through it. It is "
     "static in print now and flows once, at the top of page 1, as a masthead with the link list dropped. "
     "(2) WORSE, AND NOT REPORTED: screenshots are lazy, a figure starts at opacity:0 and its img is "
     "only created when an IntersectionObserver fires, so printing a page without first scrolling to "
     "the foot of it exported blank gaps where the pictures should be. Nothing errored; the img simply "
     "never existed. beforeprint cannot fix that alone (it is synchronous and will not wait for a fetch "
     "and decode), so the figures are now prefetched once the page goes idle, with beforeprint kept as "
     "the backstop. The export that prompted this was only correct by luck: it was taken after reading "
     "the whole page. Beyond the fixes: @page margins so Chrome's Default is ours rather than its own; "
     "print-color-adjust:exact, because on this site the tints carry meaning (amber exposure, green "
     "assurance) and Chrome drops backgrounds unless asked; break-inside:avoid on walkthrough rows, "
     "figures, notes, tables and transcripts, which fixes captions stranded on the page after their "
     "picture; orphan/widow control; live vault embeds hidden and labelled rather than exported as "
     "empty boxes; and a print-only source line carrying the canonical URL, since Chrome's own "
     "header and footer are frequently switched off. Finally, any page containing walkthrough rows "
     "now asks for LANDSCAPE, in portrait the two-column grid falls below the 820px breakpoint and "
     "collapses, which loses the alternating left-right rhythm that is the entire design of those "
     "pages. The reader can still override it. None of this was tested before; all of it is now."),
    ('v0.2.29', '2026-08-17', 'obj-cas-imm-8a68f249bd9f',
     "The first vault to get depth rather than a page. Risk Graph Explorer now has three: the overview, THE SEVEN VIEWS EXPLAINED, and THE AUTHOR'S WALKTHROUGHS. The views page captures each of the seven tabs from the live vault under the Exposed preset (the estate, context, role risk map, risk chains, the register, acceptance, what happens next) and explains the mechanism behind each, matched to how the author describes it in his own recorded demos. Highlights the screenshots alone would not carry: 'assigned' versus 'through' on the role map (what you personally hold, versus what arrives because the graph says it must), so that no risk is orphaned and every path terminates at the board; risk chains running inherent-to-corporate left to right, clickable in both directions ('leads to' navigates up, 'led by' walks back to the answers that caused it), with cycles drawn as dashed edges because the cycles are real; and acceptance as the place where the register stops being a document, with the author on camera disagreeing with his own tool, which resolves into WHICH FACT IS WRONG rather than whose judgement wins. Also captures the same organisation under the Typical and Governed presets, because that comparison is the whole argument: the org chart does not change, only what is true about the agent. The walkthroughs page carries all three videos with FULL TRANSCRIPTS, a video is invisible to a search engine, to llms-full.txt and to any agent reading this site as documentation, so the transcript is the content and the video is one rendering of it. Tooling: the shots component gained a data-dir override so deeper pages under a vault share ONE image folder; the site now runs to 53 pages. Three bugs caught by testing rather than assumption, a malformed selector expression that silently created no images at all, escape sequences leaking as literal text, and a page that never loaded the component it depended on."),
    ('v0.2.28', '2026-08-17', 'obj-cas-imm-6008b0bdfe27',
     "Seventh vault, and the method written down. RISK GRAPH EXPLORER (3simlnqe) is the fact-to-risk explorer extracted out of the risk-mandate work into a vault of its own, answer questions on the left, seven views recompute on the right, nothing leaves the page. Its argument is visible in two screenshots: empty at 0 facts / 0 risks, then 18 / 37 / 14 under the Exposed preset, because a register that produces the same output for a scratch service and a payments platform is a checklist, not a register. Unanswered relationships are drawn as ghosts, recording absence as information rather than as an implicit pass. It is the first vault here PUBLIC BY DESIGN: it carries its own PUBLIC.md whose three rules its build enforces, nothing private committed (the gate scans every file, not just the artefact), no write token, and NO METERED CAPABILITY, because a published read key in front of an LLM config is an open tab on somebody else's budget. That third rule is not in our guidance and is the one to adopt. It also sent us back to re-audit ourselves: risk-mandate does carry an LLM config, so we took its sealed credential and attempted to open it with the read key we had published, AES-GCM refused (InvalidTag), so no budget was exposed; the rule is satisfied there by sealing and here, more conservatively, by absence. Checked rather than assumed, and recorded either way. Its app.json is `permissions: {}`, the floor of a scale the catalogue now spans end to end. Zero audit findings, the cleanest yet. NEW PAGE: /demos/vaults/publishing.html, the seven steps behind every vault published here, written to be followed by another site's agent: classify the credential before it touches anything, derive rather than refuse, audit with the read key across every file, derive the facts, capture evidence by driving the real product, write the page (describe, show, then admit), and record what outlives it. It names the five tools and, more usefully, the five mistakes that produced each rule."),
    ('v0.2.27', '2026-08-17', 'obj-cas-imm-6f1decd85ad3',
     "Sixth vault: AGENTIC BROWSER ISOLATION (0610gsp9), a living risk graph for one decision, does an AI agent browse inside the user's browser with their logged-in sessions, or inside an isolated browser with a scoped identity of its own. It is this site's own ambient-authority argument made by somebody else and in much more detail, so it is linked from the AI-agents use case. Seventeen app entry points, the most in the catalogue: a numbered narrative spine, one page per stakeholder altitude (IT · CISO · DPO · CFO · COO · CEO · Board), an explorer, two graph views and the raw data, over ~70 JSON files with RDF tooling vendored in so graph exploration works offline. The mechanism is the part worth copying: every altitude has one named owner, a risk stays PENDING until that owner accepts it personally, only an accepted risk escalates, and there is no deny button, the screenshots show IT holding five pending while every altitude above reads 'waiting', because nothing has been passed up. Its app.json declares fs.write: [], an app that requests no write capability at all, which with supplement-stack (one folder) and risk-mandate (LLM use without the key) gives the catalogue three distinct points on the permissions scale, each declared in the vault rather than configured on a server. The intake check earned its keep on arrival: the submitted credential was again a vault key, refused for publication by shape, and only the one-way derivation published. Audit clean across 104 files, the six scanner hits were all digit runs inside a minified RDF library matching a phone-number pattern, recorded because ruling hits out by reading them is what an audit actually is. Also: upgraded the CLI to v0.15.0 (latest) specifically to re-test the prefix gap; it still derives the wrong ref from the canonical read-key prefix while the bare form clones correctly on the same binary, so the brief now says 'confirmed on latest' rather than 'may be a stale install', and the machine-verified check updated its own evidence from v0.14.27 to v0.15.0 with nobody editing the claim."),
    ('v0.2.26', '2026-08-17', 'obj-cas-imm-6958c20644d8',
     "Credential intake becomes a check rather than a habit, prompted by yesterday's near-miss: a vault key was submitted for publication described as a read key, caught by shape, and only its derived read key published. The catch depended on somebody looking. New admin/build/check_credential.py classifies a credential BEFORE it touches a page, a catalogue entry or a commit, exit 0 means read-only and publishable, exit 1 means a write credential and stop. It works two ways because the problem has two eras: by PREFIX for vaults new enough to emit one (the canonical write prefix, which is exactly the change being rolled out, and the reason it is worth rolling out), and by SHAPE for everything older, where a read key is 64 hex characters and anything else before the colon is a passphrase. Verified against all eight real forms we have handled, including the actual key from yesterday, which it refuses with no prefix to help it. If a submission IS a vault key the entry is not blocked, the documented one-way derivation is printed instead. The rule is now in the catalogue vault's SCHEMA, so it renders on /catalogue/ with no site deploy, and summarised on the vaults index. Also: the release tripwire got more precise rather than more strict. Banning the bare write prefix outright had caught its own author three times, each time writing it in prose, and guidance that cannot show a reader what a write credential looks like cannot teach them to spot one. It now fires on the prefix followed by a CREDENTIAL character, so documentation may name it while every real key still fails the build; the bare passphrase:vault_id shape catches the body independently. Both directions proven before shipping (\\S was tried first and failed, because in documentation the prefix is followed by markup)."),
    ('v0.2.25', '2026-08-17', 'obj-cas-imm-8b39926aadb1',
     "Risk Mandate joins the catalogue, the most complete project ever published here, and the first to demonstrate CAPABILITY WITHOUT CREDENTIAL. It is a Black Hat field demo (hand someone an iPad, answer eight questions, a risk register assembles) built AS a vault app: 124 files, 98 commits, eight entry points, a test suite, build tooling, releases pinned to commit ids and offered as a live selector in the app chrome, and offline operation after one cached load. Its app.json grants llm:chat/models/usage/listen and fs.write over field/workspace/ and nothing else, while the OpenRouter credential itself is SEALED under the vault key in .vault/llm/config.json, so the host decrypts and calls and the app frame is handed results, never a secret. Auditing with the published read key, that field is ciphertext we cannot open, which is the claim demonstrating itself. The /compare/ privilege vocabulary gains the shape this revealed: ops and bearer come apart, so ops:llm-chat can be granted while bearer of the key is withheld, something most sharing models cannot express, because handing over the capability and handing over the credential are the same act. IMPORTANT PROCESS NOTE: the credential submitted for this vault was a VAULT KEY (format 3, passphrase:vault_id), not a read key, it matched this site's own banned-shape tripwire exactly. It was not published. It was stored in the gitignored local tier and the read key was derived from it one-way via the library's own Vault__Crypto.derive_read_key; only that derivation appears on the site, in the catalogue, and in the capture rig. The independent audit across all 124 files was clean, with two findings of the good kind recorded publicly: the sealed LLM key, and a single secret-scanner hit that turned out to be a deliberately fake sk-test- key inside a test asserting that a reachable API key IS caught."),
    ('v0.2.24', '2026-08-17', 'obj-cas-imm-244c5ecdeecf',
     "Comparisons, built as reproducible tests rather than as claims, the concrete proposal answering the 16 Aug comparison brief. New /compare/ section carrying (1) the entry format: task, steps, prerequisites, privileges granted, where it runs, survives-the-vendor, verified date, and how to re-run; (2) a PRIVILEGE VOCABULARY, which is the part most likely to be argued with and the reason it is published first, seven properties that make two grants comparable (scope, operations, bearer, mediation, duration, withdrawal, observability), under which a published read key reads scope:vault · ops:read · bearer:any-holder · mediation:key · duration:forever · withdrawal:future-only · observability:none, three of them WORSE than a mainstream sharing link; (3) three worked entries, printing a markdown file (small, checkable in a minute, we win), taking access back after sharing (WE LOSE, plainly: rotation protects future commits and reaches nothing already fetched, while a server-mediated platform simply refuses the next request), and letting a program record data without letting it alter records (the differentiator, where step counts tie and privileges separate). Behind it: admin/build/compare_tests.py executes the our-side claims against the live service with published read keys only, and writes compare/results.json, six checks, each stating what would make it FAIL. Five hold; one records an ABSENT capability and is kept until it stops reproducing: the installed CLI v0.14.27 does not strip the canonical sgit_rk1_ prefix the web loader now accepts, deriving the wrong ref id, while the bare form works on the same version, now also filed as a brief to the CLI team. The freshness mechanism is real rather than a date in small print: results carry an expiry threshold and a past-threshold result renders as UNVERIFIED instead of as fact, proven by forcing the dates old and watching all six rows flip. The page states its own asymmetry up front (our rows are machine-verified, rows about anyone else's product are hand-checked on a date) and lists what is deliberately absent, including the deployment comparison, which waits until our own portable-artefact/hosted-viewer caveat is resolved."),
    ('v0.2.23', '2026-08-16', 'obj-cas-imm-8af209891454',
     "Structure for scale, and the most interesting vault yet. (1) Every published vault is now a self-contained folder, demos/vaults/<slug>/index.html with its screenshots in demos/vaults/<slug>/images/, so adding the hundredth vault is adding a folder, and a vault's page and pictures move together. The old flat /vaults/ section is gone (URLs lived one day). Two engine changes made it possible: the root-prefix was computed as 'one ../ if nested at all', which silently pointed the nav, stylesheet and every asset at the wrong level once pages nested three deep, now it is ../ times the depth, the same formula the markdown twins already used; and the screenshot rig writes per-vault, with a --vault filter. Image paths in the walkthrough are page-relative for the same reason. (2) New vault: SUPPLEMENT STACK (r7zes477), and it is the strongest healthcare demonstration on the site. Its idea: every label describes one product, nothing describes the sum, so the model extracts (fuzzy, with every amount traceable to the label photograph it was read from) and the code adds up (deterministic, rules stated in the open, a missing value flagged and never guessed), producing a briefing for somebody qualified and never a verdict. It is also the first vault here to use SCOPED WRITE PERMISSIONS: app.json grants write over adherence/ only, so the app that logs what was taken cannot alter the regimen, the labels or the references, least authority as a property of the vault rather than a server setting. Five walkthrough rows captured from the live vault, including its app.json permission block. The healthcare use case now opens with this as a worked example: who holds the data, how it reaches a clinician (a read key, not an account or a PDF), where AI fits safely, and why the reference set is UK RNI/EFSA rather than US Daily Values. The published audit: clean on credentials, no personal identifiers found, but it publishes a real regimen and the health context inferable from it, deliberately, and revocation is not retroactive."),
    ('v0.2.22', '2026-08-16', 'obj-cas-imm-b1d27e3295cf',
     "The walkthrough: six alternating rows at the foot of the Algarve page that explain what a live embed cannot say for itself. The app is real HTML (not a viewer template); clicking a photo opens the vault's own lightbox with captions from gallery.json; the debug pane's Vault tab times the decryption step by step; its REPL tab is a console over the sg.* bridge where vfs.write is refused because no write capability exists in a read key; the vault browser shows photos/originals and the app's own SOURCE; and the SGit tab carries 36 commits, because the history IS the storage. New tool behind it: admin/build/capture_shots.mjs drives the real product with only a published read key, performs each row's navigation (scroll to a chapter, click a photograph, open the debug pane through the HUD's shadow root, type vfs.list into the REPL, expand a folder, switch to the SGit view), crops the result and writes WebP, so the pictures are of the actual vault and regenerate when it changes. Getting there needed four fixes worth recording: the app frame must be identified by a selector it contains (the shell frame also has text and was winning the race), the REPL input sits two shadow roots deep and is reached with a shadow-piercing locator typed into for real, the file tree rows are .sb-tree__folder-name, and the slow test mirror needs navigation timeouts well past the 30s default. Images load through assets/shots.js, lazily, and via fetch-or-sg.vfs rather than <img src>, because the authoring contract forbids declarative refs so every page survives being served from inside a vault. Also adds a direct 'open the gallery app in its own window' link using the read-key fragment the UI now accepts."),
    ('v0.2.21', '2026-08-16', 'obj-cas-imm-10164a7a31ab',
     "Two reader reports from an iPhone, both fixed with the cause named. (1) The vault pages now open both surfaces ON LOAD, the reader lands on the vault, not on a row of buttons. The opens are sequential by design: the browser surface starts once App Mode reports vault-ready (grace-capped), so its objects come out of the client's encrypted-object cache and the second open mostly decrypts rather than fetches, the caching architecture demonstrating itself on every page view. Buttons are gone; each frame carries a label and its own status line, and a failed handshake leaves a retry control instead of a dead page. (2) The landscape-iPhone report, site and iframe not using the full width after a pinch-zoom, was hunted down empirically rather than guessed at: one unbreakable 702px token (the sgit clone command with its 90-character credential) on a 393px viewport made the page 743px wide, which drops Safari's fit-to-width scale below 1; pinch out and the entire site sits narrow with a white gutter. Fixed with overflow-wrap:anywhere on inline code (breaks only when a token would overflow) plus the html canvas painted the site colour so any zoomed-out or overscrolled area reads as the site rather than as white margin. Verified at iPhone viewports: every checked page now measures exactly the viewport width, and the auto-open completes with zero clicks."),
    ('v0.2.20', '2026-08-16', 'obj-cas-imm-23e6ea08a77d',
     "Both surfaces at once, reader-requested: on the vault pages, App Mode and the vault browser now each open in their OWN frame, stacked (the app first, the FILES/SGIT/SETTINGS browser under it) instead of one frame the buttons fought over. The second open is fast by design and the component says so: the encrypted objects are already in the client's cache from the first surface, so the second mostly decrypts rather than fetches, the caching story demonstrating itself. Each frame carries its own status line from its own handshake (one listener, replies routed by which frame sent them). The Full screen button is gone from these pages, with each surface getting a full-width, viewport-height frame of its own, it earned nothing. Verified headless: both surfaces open (5.0s through the slow test mirror, faster live), app above browser, two independent vault-ready events."),
    ('v0.2.19', '2026-08-16', 'obj-cas-imm-2773e0dfba35',
     "The site starts doing the thing it was building toward: publishing vaults. New /vaults/ section, an index of every read key this site has deliberately published, and one page per vault (five at launch: Field Notes, Strategy Maps, Deploy Docs, the Catalogue itself, and, new, Algarve · May 2026) with a real description, the features that vault exercises, what the shape is good for, the derived facts, the key as a copyable sgit_rk1_ credential with a CLI command and an open-in-the-official-UI link, and the vault RUNNING LIVE in the page via the reusable embed component (assets/vault-ui-embed.js, the two-surface embed-protocol host from the demo page, now attribute-driven; App Mode hidden for vaults without an app). Pages load the component contract-compliantly (fetch+eval, no script src, every page must survive being served from inside a vault). The Algarve vault is the first entry processed through the catalogue's submission queue as designed: read key supplied in chat, everything else derived, 71 files, 29 MB (60 WebP photos in originals/web/thumbs), 36 commits, auto-opening gallery app with a chaptered narrative. Its pre-publication audit is published on the page per the rules: one finding (a live delete_auth token in public-preview bookkeeping, narrow scope, the owner advised to rotate) and one counterpoint worth showcasing (the same vault's readonly-tokens bookkeeping decrypts to further ciphertext: owner secrets double-encrypted, the pattern that fixes the finding class). Catalogue vault updated in the same breath (new entry, trip-gallery-1 ticked off the awaiting list (7 remain)) and /catalogue/ picked it up with no site deploy, which is that design working."),
    ('v0.2.18', '2026-08-16', 'obj-cas-imm-68578f7b1bf4',
     "The tag pipeline's first contact with a real GitHub rule, fixed within the hour. v0.2.17's run DID tag itself, the first CI-authored tag, but the historical backfill push was rejected wholesale: a workflow's GITHUB_TOKEN cannot push any ref pointing at a commit whose tree carries a different version of a workflow file, and the `workflows` permission that would allow it is not grantable to that token. Every pre-v0.2.17 release predates the current deploy-pages.yml, so all 33 backfill tags bounced, and because the tag job failed, the deploy was skipped and v0.2.17 never reached the live site (this release carries its changes out). The fix splits the pushes by what they are: THIS release's tag at HEAD is load-bearing and fails the job if rejected (it never should be, HEAD's workflow blob matches the branch, which is why v0.2.17's own tag went through); the historical backfill is best-effort per tag, warns per rejection, and emits one notice with the single human command that completes the set, `git push origin --tags` from any workflows-scoped credential. Once a human has done that once, the backfill loop becomes a chain of no-ops. Lesson recorded for the case-studies pile: the same both-remotes discipline that made CI verify-and-tag instead of commit-and-tag also meant the failure cost nothing but a skipped deploy, no bump commit was stranded on one side of the two-VCS split."),
    ('v0.2.17', '2026-08-16', 'obj-cas-imm-24876be44e37',
     "Two reader-driven fixes and the release pipeline grows tags. (1) Layout: the embed buttons and their status line sit back in the text column where they belong, only the vault surface itself breaks out wide, since it is the thing with the big UX. (2) CI tagging, ported from the VoiceDebrief website pipeline (validate → tag → publish, every push to dev a minor release tagged v{release}.{major}.{minor}) with one deliberate adaptation: the upstream OSBot action has CI commit a version-file bump, but this repo is simultaneously an sgit vault, and a CI-authored commit would exist only on the git side, breaking the both-remotes-in-sync invariant release.sh enforces. Here CI verifies-and-tags instead of owning: SITE_VERSION (bumped once per release by release.sh) must match the release commit's subject and be the next minor after the latest tag, then the commit is tagged. No CI commits, no drift, same discipline, and a forgotten bump now fails the pipeline loudly instead of shipping quietly. The first run backfills tags for every historical release by parsing the commit subjects, so the whole v0.1.9→now history becomes navigable by tag. Also: validate.js now runs in CI as the gate before tagging and publishing, which puts the key-leak tripwire on the deployment path as well as the release path. (Tags could not be pushed from the authoring session, the session's git proxy authorizes branch pushes only, which settled the design question of who owns tags: CI does.)"),
    ('v0.2.16', '2026-08-16', 'obj-cas-imm-34ed83dd03e6',
     "The embed grows up: both official surfaces, one click each, over the UI's new embed protocol. Reading the deployed bundle found what the addendum's Phase 3 had shipped, embed-protocol.js and a shared embed-receiver on BOTH shells: the host page loads ?embed=1&parent=<origin>, the frame proves itself with vault-embed-ready, and only then is the key sent by postMessage with the targetOrigin pinned. Strictly better than the URL-fragment flow shipped yesterday: the key never appears in any URL, is never written to the frame's storage (verified, sessionStorage and localStorage empty after open, memory-only as the protocol promises), and the host gets structured vault-ready/vault-error events instead of guessing from load timings. The demo page now has two buttons: App Mode, and, new, the vault browser with the FILES/SGIT/SETTINGS rail, which previously needed a two-step trick because /en-gb/vault deliberately strips URL hashes and root is the only inbox. Headless-verified both: App Mode ready in 2.7s, browser in 9.7s, rail present, R1 W0 badge, no horizontal scroll. The embed area breaks out of the text column (94vw up to 1680px, height tracking the viewport) because the vault browser is a full working surface, plus a full-screen button, both asked for by a reader with big-UX vaults. What is still not possible is stated precisely on the page: vault-open carries {key, mode, deepLink} where deepLink is a file path, so a host can select a SURFACE but not a VIEW, SGIT and SETTINGS remain in-page events. The briefing gains an addendum thanking the UI team, recording the verification, and sharpening the last ask to one optional field: view:'files'|'sgit'|'settings' on vault-open, applied after mount. Also fixes a stale paragraph v0.2.15 left behind (a silent curly-quote replace failure), the page no longer promises a swap that already happened."),
    ('v0.2.15', '2026-08-15', 'obj-cas-imm-576ba8a1a09f',
     "The gap closed: the official SG/Vault interface now opens from a published read key, and this site embeds it. Our v0.2.7 experiment had isolated the blocker to exactly one thing, the loader documented a read-only credential but rejected it, and the CLI shorthand was parsed as a passphrase and PBKDF2'd into the wrong ref id. The UI team shipped the fix: a read-key credential is now its own format (<64-hex>:<vault_id>, the shape sgit clone already accepted) and is tested BEFORE the passphrase formats, which was the precise ordering bug; canonical CLI key prefixes are stripped first. Re-running the same experiment against the deployed build, read key only, no vault key anywhere: all three credential forms parse; App Mode boots the Field Notes demo under full chrome with its six studies rendered and the bridge live; the vault browser opens with the FILES/SGIT/SETTINGS rail over the real decrypted tree; and the SGit view lists both commits with real object ids, all inside a cross-origin iframe, with an explicit R1 W0 / Read-only badge. So /demos/vault-app-embed.html now carries TWO hosts and keeps both on purpose: the ~170-line minimal host that shows the protocol with nothing hidden, and the official UI opened with the same published key. One ask stays open and is stated as such, no URL selects a view, so the SGit inspector cannot yet be framed in isolation. Also: the release tripwire learned the CLI's canonical WRITE-key prefix (its read-only sibling is deliberately exempt, we publish one), proven by making it fail before trusting it; it promptly caught this very release note's first draft, which had spelled the banned prefix out while explaining it. The hub capability audit is updated with verified evidence, row 1, the entry point for everything, flips from partial to present, which settles the spec's assembly-not-construction question for the forge's read-only tier."),
    ('v0.2.14', '2026-08-15', 'obj-cas-imm-2f3c360b2de1',
     "Ask B of the 14 Aug pack begins: the hub.sgit.ai briefing-pack plan lands in admin/plans/, and, per the spec's central instruction, the capability audit comes before any architecture. The audit table is already 13 rows deep, seeded entirely from evidence this project has produced: verified present (open-from-read-key in our readers, single-object decrypt, the app runtime under a sandboxed iframe, sparse per-object fetch, cross-session caching with the 120s ref window, frameability), verified partial with the exact gap named (the official UI parses but rejects its own documented read-only credential format), and honestly unknown (client-side merge, the in-UI diff and history views, enumerated in the bundle, never driven). The partial rows are flagged as the dangerous ones, because a partial capability gets assumed complete. The plan also fixes the pack's shape: six parts in order, the four absences stated up front, surfacing/adding/absent applied per feature (blame is adding, not surfacing), permissions as worked key topologies, and the private-vault key-handling flow named as the one needing a considered position. The crawler question is closed as answered: full text in the served HTML, noscript reveal since v0.1.26, and Google indexing confirmed."),
    ('v0.2.13', '2026-08-15', 'obj-cas-imm-35d4e3e3a906',
     "Fix: the catalogue page shipped without the vault debug panel markup that the shared reader wires unconditionally, so the reader threw on two missing elements and the first document never rendered (navigation and clicked entries worked; the initial body stalled at the fetching message). The panel is now on the page (which it should have been anyway, since watching the ciphertext arrive is half the point) and the headless check confirms the README renders on load with zero page errors."),
    ('v0.2.12', '2026-08-15', 'obj-cas-imm-15dbcf053020',
     "The catalogue: a vault indexing vaults, including itself. The 14 Aug briefing pack's Ask A lands as designed, a submission queue whose per-entry cost is a read key and one line, with everything else derived by opening the vault. The deriver (admin/build/catalogue_derive.py, ~120 lines, read-only, no token) turns a read key into file count, plaintext size, commit depth, HEAD, top-level layout, file types, app entries and browser-renderability; proven on all three published-key vaults (Field Notes 4bshby5n, the strategy/maps vault ookq4mn4, both app entry points detected, and the deploy-docs vault fyofmkvr, markdown-only). The catalogue itself lives in a new vault (kc67yhgw) published with its own read key and listed in itself: README (how to submit), SCHEMA (supplied-vs-derived, and the two rules, read keys yes, vault keys never; escrow the write key BEFORE publishing, because a frozen vault can never be corrected), three processed entries, and the two public to-do lists the brief asked for, awaiting-a-read-key (seeded with eight vaults named in the memos, each carrying the pre-publish audit instruction the strategy-maps case taught) and awaiting-processing (the agent's queue, currently empty). /catalogue/ renders it live via the same reader as the deploy docs, updated by pushing to the vault, no site deploy. Write-key status is a first-class field: 'known and escrowed' or 'lost', stated publicly per entry."),
    ('v0.2.11', '2026-08-15', 'obj-cas-imm-1f8635c410ef',
     "sgit gets its own Wardley map analysis, six maps starting with git at full strength, because a map that flatters its author is not a map: version control today (git's moat is the platform layer, resting on readable storage), the files that cannot follow (a hole in the map where their foundation should be), sgit's move (no new verbs, invert the bottom layer), the boundary on one map (two chains from one team, split by 'may the store read this?'), agents as the new user (the serialised diff versus ambient authority), and the strategy (commoditise private version control). The maps are drawn as inline SVG by a ~90-line renderer (no images, no dependencies) and the analysis ships as a SECOND app inside the same vault as the SG/Send strategy essay (ookq4mn4): one encrypted store, two entry points, one published read key; the embed opens it by passing entry to the same host. The embed shim gained link handling, in-page anchors scroll manually (assigning location.hash re-navigates a srcdoc frame) and relative .html links remount the frame on the new entry, so the two apps cross-link inside the embed; verified headless: 6 maps, 42 nodes, 12 evolve arrows, and clicking the companion link lands on the strategy essay. Linked from the Why page's boundary section. Also: Google has confirmed indexing sgit.ai, which unblocks the component registry when its turn comes."),
    ('v0.2.10', '2026-08-15', 'obj-cas-imm-cedfb3d06f6a',
     "Plan bookkeeping: the why-expansion plan's status table now reflects reality, Why reframe done, serialised PR done with its CLI brief, two of three demo vaults live, embed at the minimal-host stage pending the UI team's credential fix."),
    ('v0.2.9', '2026-08-15', 'obj-cas-imm-2ce42cfd214e',
     "The two remaining pieces of the briefing-pack plan land. (1) The Why page is reframed from rebuttal to boundary map: it now opens with where git wins, then draws the boundary precisely. The operations are not the gap (commit through merge all exist; proposing reviewable changes without write access is present, as a serialised diff, and is a differentiator); what is absent is the hosted review interface and the ecosystem above the protocol; and git is also client-side, so the real difference is that the objects are encrypted there, with the losses stated as a given-up/in-exchange-for table. New protocol section: the six-step read path verbatim, the two keys named explicitly, the three modes (Local/API/Web), and the two-implementations proof point. The LinkedIn comment and the market answer move below the boundary, kept whole. (2) New lead use case: the serialised pull request, no credential issued at all, grounded in the 5 Aug Black Hat disclosure, with an honest shipped-vs-pattern table (emit exists as history diff --json; import is not first-class) and evidence status PARTIAL. The matching brief to the CLI team asks for sgit diff export/apply, a published diff format, and ignore-file support, the latter now a prerequisite for the one-folder-two-VCS pattern the site publishes."),
    ('v0.2.8', '2026-08-15', 'obj-cas-imm-4060eca3121d',
     "Second demo, and the first with real content: The Strategy in Seven Maps (the actual SG/Send strategy, published on LinkedIn in May 2026) served live from a vault with a published read key. The page also publishes the audit that made this interesting: the original vault could NOT publish its read key, because its own read-write credential was written inside its content (a production briefing quoted the clone command verbatim), server-side bookkeeping under .vault/owner/ carried live delete_auth tokens, and the vault's keys derive from a legacy low-entropy token. The fix is the pattern the page teaches: republish, don't retrofit, sanitised copy, credentials redacted with a visible note, fresh full-entropy vault (ookq4mn4), and only then a published read key; a republish also sheds the history you cannot publish. The embed host gained vault-path image support (a MutationObserver swaps img.src vault paths for blob: URLs read over the bridge, the same job the real host's interceptor does), verified: all eight Wardley Map PNGs travelled as ciphertext and rendered."),
    ('v0.2.7', '2026-08-15', 'obj-cas-imm-1b9cd77fb6d7',
     "The full-UI embed experiment, run and published. Driving the real SG/Vault interface framed inside a page: the UI is frameable (no X-Frame-Options, no frame-ancestors), and App Mode works completely inside a cross-origin iframe, with a valid credential the official app-shell booted the Field Notes demo under the full HUD chrome. The one gap is the credential: the loader documents a read-only format (vault_id + 64-hex read key) but the client rejects it, and the CLI's 64hex:vault_id shorthand gets PBKDF2'd as a passphrase and derives the wrong file ids. No URL selects the SGit view, either. Both are now precise, evidence-backed asks in the UI-team briefing, honour the documented format (which alone makes the official UI embeddable with only the published read key) and add a |view:sgit deep-link. The demo page carries the findings table."),
    ('v0.2.6', '2026-08-15', 'obj-cas-imm-966aed3bd862',
     "The first demo ships: a vault app running live inside a sgit.ai page from a published read-only key. A new vault (Field Notes, 4bshby5n) was created from scratch for it (a self-contained app following the authoring contract, generative SVG art, content in content.json read over the bridge) and /demos/vault-app-embed.html is the complete walkthrough: init, commit, push, derive the read key, publish it deliberately, embed. The embed host is assets/vault-embed.js (~170 lines): HMAC-derived ids, ciphertext over CORS, Web Crypto decryption, the app booted in a sandbox=allow-scripts iframe with an opaque origin, and its sg.vfs/loadCss/loadJs calls answered over postMessage, the same shape as SG/Vault's vault-in-vault, minimal by design until the UI team answers the reuse briefing. Also rewrites the one-tree-two-remotes ordering section as a clean rule (the discovery narrative is gone), adds the Demos nav section, and extends the key-leak tripwire to scan for every demo vault's passphrase, not only the site's own. Verified end to end in headless Chromium: bridge live (the app's status line reads content via sg.vfs.readText from the vault), six tiles rendered, frame origin opaque, mutations impossible by construction."),
    ('v0.2.5', '2026-08-14', 'obj-cas-imm-fedcfbd348c0',
     "Corrects the one-tree-two-remotes case study, prompted by a reader question: if sgit pushes first and git commits after, don't the files match? They do, tested rather than argued. With that ordering git captures the freshly written ref every time and a clean tree is the normal end state of a release; reads (ls, history, status, vault info), no-op commits and pushes, pull and fetch were all tried and none rewrites the ref. The section is now a rule about ordering rather than a claim of permanent drift, keeping the part that is true and useful: when the ref IS dirty, the bytes tell you nothing, because a rewritten ref never byte-matches even when it decrypts to the same commit."),
    ('v0.2.4', '2026-08-14', 'obj-cas-imm-854db222f8cb',
     "Wider layout across the site. The reading column was a classic 720px prose measure, which squeezed the content into the middle of a modern display and made every page longer than it needed to be. The measure is now 960px and the wide container 1360px, with the body type up a step (.93rem to .98rem) so the longer line keeps a comfortable character count; the home-page sections (hero, terminal, feature grid, cards) scaled in proportion, and the deploy-section nav column widened with them. Verified at 1600px (no overflow, content fills the frame) and at 390px (no horizontal scroll)."),
    ('v0.2.3', '2026-08-14', 'obj-cas-imm-6deb6376d9bd',
     "Plans and briefs from the 14 Aug briefing pack. Publishes the implementation plan for the Why reframe (boundary map), the serialised-pull-request lead example (with one honesty gap found while planning: the diff emit exists as history diff --json, but the CLI has no apply/import command, so the page will ship as PARTIAL and a brief goes to the CLI team asking for sgit diff export/apply plus a published diff format), three end-to-end demo vaults with deliberately published read keys, the embed-reuse work, and the component registry (components, never plugins, plugin stays reserved for capability grants; registry gated on indexing being observed). Files a briefing to the SG/Vault UI team with six concrete questions about reusing their app-iframe host code inside sgit.ai pages, linked from the briefs page."),
    ('v0.2.2', '2026-08-14', 'obj-cas-imm-29aa935bd44a',
     "New case study: one working tree, two version control systems, the workflow this site is actually developed with. One folder is both an sgit vault and a git repository; a release is two pushes of the same tree. The page covers what each remote carries, the one-file .gitignore boundary that makes it safe (everything encrypted is committed; only the plaintext local/ tier is excluded), and the finding that surprised us: the encrypted ref always looks modified to git because AES-GCM uses a fresh IV per write, so for that one file git status cannot detect staleness, proved by decrypting both sides to the same commit id. Ships admin/build/release.sh, which makes the discipline mechanical: build, validate (the key-leak tripwire gates BOTH pushes, not just the deployed one), push sgit, push git, and refuse to finish unless both remotes report in sync. The script deliberately never invokes commands that echo the vault key."),
    ('v0.2.1', '2026-08-14', 'obj-cas-imm-49cf3a524b0c',
     "Fixes a gap the v0.2.0 restructure opened: llms.txt is generated by walking a list of known sections, and the new case-studies section was not in it, so all three of those pages were silently absent from the machine index, present on the site, invisible to any agent reading llms.txt. The section is added, and the generator now refuses to build if any page would be omitted, which is the same guard as the orphan-page rule and for the same reason: a page nothing links to and a page the index does not list are both unpublished."),
    ('v0.2.0', '2026-08-14', 'obj-cas-imm-d8f580db4d0f',
     "Structural release, the middle digit moves, as the numbering note below promised it would. Two changes. (1) The root held 19 files and every page body was inlined in a 2,709-line generator; bodies now live one-per-file in admin/content/ with a pages.json manifest, and the generator is a 648-line engine that does not grow as the site does. Adding a page is a content file plus a manifest row; the build then produces the HTML, the markdown twin, the llms.txt row, the llms-full.txt section, the sitemap entry and the canonical/OG/JSON-LD tags. The refactor was verified output-preserving before any page moved: all 31 pages byte-identical. (2) Sections became folders (why/, try/, security/, skills/, briefs/) leaving 9 files in the root, all of them ones that must be there. New case-studies/ section, which is where the leaked-key incident and the live-vault architecture now live; they were filed under docs/ and deploy/ where nobody looking for a case study would find them. Links were rewritten mechanically by resolving each href against the old path and re-expressing it from the new one, then verified by crawling every internal URL from the homepage: 29 URLs, zero broken."),
    ('v0.1.27', '2026-08-14', 'obj-cas-imm-87daa0751f0d',
     "Entity disambiguation, after checking what search actually returns. Google's AI Overview for the bare query already knows this project, and sources it from PyPI, because the PyPI page never linked here and this site was not in the index. \"sgit\" is also an Android Git client, an Indian engineering college and a class of shell shortcut, so the job is not only being indexed but being resolved to the right thing. Every page now carries JSON-LD: a SoftwareApplication node with sameAs pointing at the PyPI and GitHub identifiers that already rank, an explicit disambiguatingDescription naming the collisions, and a per-page TechArticle node. Google's own 2026 guidance says structured data is not required for generative-AI features. This is here for entity resolution, not ranking. The validator now fails the build if a page has no structured data or if any JSON-LD block does not parse."),
    ('v0.1.26', '2026-08-14', 'obj-cas-imm-5da2eb791bb2',
     "Acts on an inbound brief from an agent that tried to use this site as documentation and could not. It reported that the site did not rank for its own positioning language and guessed the pages might be client-side rendered. They are not, the full text is in the served HTML, but the fade-in that hides the unstyled flash left body{opacity:0} until a JavaScript bootstrap ran, so any client applying our CSS without running that bootstrap rendered a complete but entirely invisible page (measured: 15,733 characters at opacity 0), which is also a hidden-text signal to an indexer. Fixed with a noscript override and a CSS-only reveal failsafe, both now enforced by the validator. Adds the crawler surface that never existed: robots.txt, a generated sitemap.xml covering every page, and canonical plus Open Graph tags. Adds llms-full.txt, every page in one document, for the very common agent harness that will not follow a link out of a fetched file, and makes llms.txt self-sufficient: inline answers to the common questions (including exactly which git operations exist and which do not) and per-page key facts, on the brief's observation that for such an agent the descriptions are the only content it will ever see. The brief and what changed are published on the briefs page."),
    ('v0.1.25', '2026-08-14', 'obj-cas-imm-1861dd16dd11',
     "Three changes that go together. (1) Every page now has a .md twin at the same path, generated from the same content so the two cannot drift, with internal links rewritten to point at markdown, an agent can traverse the whole site without parsing HTML, and llms.txt is now generated from the page registry rather than hand-maintained. (2) A git-and-sgit comparison on the Why page that says plainly where git is better (performance at scale, ecosystem, bisect/blame/rebase, partial commits) and how the two run side by side, as they do on this site. (3) Use cases moved to /use-cases/ with a page per situation: the problem, a working recipe on shipped commands, an honest evidence status (proven / partial / pattern), and a brief you can hand to an agent. Validator gained three rules: every page has a markdown twin, markdown links resolve, and no raw HTML leaks into the markdown."),
    ('v0.1.24', '2026-08-14', 'obj-cas-imm-3c426ddbe9e9',
     "Why page rewritten around the right question. The first version answered \"is there a market\" with verticals and a comparison table; the sharper answer is what sgit makes possible that was not possible before, so that is now the headline: six capabilities running on this site, a live site its host cannot read, read access as a publishable capability, two agents sharing a workspace neither host can read, private data with CDN economics, storage as untrusted commodity, and transit security that does not rest on the CA system (including where that goes next: the read key never leaving the client, or PKI with a client-only private key). The page now assumes the reader knows git and only covers the delta. Also corrects the business-model answer: the client being open source IS the distribution strategy, with services built on top, not \"no revenue attached\"."),
    ('v0.1.23', '2026-08-14', 'obj-cas-imm-89a142ee3598',
     "A freshness window on the mutable HEAD pointer. The ref was the last per-page-view network request left; it is now checked at most once every ref_ttl_s seconds (120 by default, configurable in deploy/vault.json), so reading inside the window costs zero requests and server load scales with readers rather than page views. The cost is a bounded propagation delay, a new commit appears within the window at worst, and \"check for new commit\" forces a fetch that ignores it. The vault panel logs the reused ref as TTL and counts down live to the next check."),
    ('v0.1.22', '2026-08-14', 'obj-cas-imm-d9ac70d78c31',
     "Kills the load-time flicker of the vault panel. The panel's remembered width and open state were restored by the reader script, which loads asynchronously, so the panel painted at its CSS default width, then jumped and slid open a beat later. Restoration now happens synchronously, before the first paint, and the slide transition is suppressed until state has settled. Opening and closing the panel by hand still animates; restoring it never does."),
    ('v0.1.21', '2026-08-14', 'obj-cas-imm-5a60bc3bf25b',
     "Object bodies in the vault panel are now syntax-coloured like an editor (keys, strings, numbers, literals and punctuation each get their own colour) and word wrapping is off, so structure survives and long values (base64 ciphertext, object ids) scroll horizontally instead of folding into a wall of text. Highlighting is applied only when the decrypted object actually parses as JSON, so markdown blobs stay plain."),
    ('v0.1.20', '2026-08-14', 'obj-cas-imm-78146d2f4f86',
     "The vault panel now links to the page that explains it. There is a compact “how this works” link in the panel header (always visible) and a fuller card at the foot of the panel pointing at deploy/how-this-works.html. The panel is where you are when the question occurs to you, so it is where the answer should be offered."),
    ('v0.1.19', '2026-08-14', 'obj-cas-imm-fe67d9589a2e',
     "Fixes the resize grip, which shipped in v0.1.18 but was unusable: it was absolutely positioned inside the panel\'s scrolling area, so it scrolled out of reach as soon as you moved down the object list, and at 6px fully transparent it was invisible anyway. The panel is now a flex shell (a fixed 12px drag rail with a visible handle, plus a separately scrolling body) and dragging uses pointer events with capture (mouse, pen and touch). Double-click the rail to reset the width."),
    ('v0.1.18', '2026-08-14', 'obj-cas-imm-6eded1c95732',
     'Vault panel becomes an object inspector: every row now shows what the object IS (ref/commit/tree/blob), WHY it was read, and, on click, its decrypted contents, pretty-printed for JSON. The panel is width-resizable (dragged from its left edge, remembered). Plus a real optimisation the panel made obvious: the path→blob index is a pure function of the commit id, so it is now memoised in localStorage, a first visit walks every tree to learn the encrypted filenames, but subsequent visits to an unchanged commit read zero tree objects.'),
    ('v0.1.17', '2026-08-14', 'obj-cas-imm-9d5b50f68679',
     'Vault panel: a "clear list" button that resets the request log and counters without touching the caches (so you can see exactly which objects one page needs); the panel\'s open/closed state now persists across navigation and reloads; cached objects record when they were stored and the panel shows their age. Navigation now scrolls to the top of the content rather than the top of the document. New page: deploy/how-this-works.html, the full architecture with hand-drawn SVG diagrams of the two-session publishing pipeline and the client-side read path.'),
    ('v0.1.16', '2026-08-12', 'obj-cas-imm-34684a789fe6',
     'New /why page answering the sharpest public criticism of the project ("I see no market or value whatsoever") directly and without marketing: who actually has the problem, why git-crypt/Dropbox/S3+KMS do not cover it, the market question answered plainly (the CLI has no revenue attached; hosting is the commercial layer; no TAM claims), where the criticism is right, and a twelve-question FAQ pre-answering the promised follow-ups.'),
    ('v0.1.15', '2026-08-12', 'obj-cas-imm-0959988dd4fe',
     'New Deploy section: self-hosting guidance rendered LIVE in the browser from an encrypted SG/Send vault, using a published read-only key, ciphertext over CORS, AES-256-GCM decryption via Web Crypto, no copy stored on this site and no rebuild when the SG/Send team pushes. Includes a three-tier cache (session memory, permanent Cache API for immutable objects, always-fresh ref) and a vault debug panel showing the HEAD commit, per-object request log, and cache hit/miss stats.'),
    ('v0.1.14', '2026-08-12', 'obj-cas-imm-3b54c02dbb96',
     'Two new pages, both linked this time: docs/exposed-vault-key.html (the rotation runbook plus the case study of this site\'s own key leak) and briefs.html (cross-team briefs, which v0.1.13 built but never registered, so nothing linked to it). New validator rule: every generated page must be reachable from another page, or the build fails. Added a fourth CLI-team ask: history-preserving rekey.'),
    ('v0.1.13', '2026-08-12', 'obj-cas-imm-5168ef3fe1a7',
     'SECURITY: the vault passphrase had been written into admin/build/validate.js as an anti-leak tripwire regex, which put the literal secret into a tracked, public file (present in 3 commits). Removed; the tripwire now reads the secret from the gitignored local/ tier and scans for it, so it can never be hardcoded again. The key must be treated as compromised and rotated. Also: a /briefs page collecting the cross-team briefs (multi-agent collaboration log), and a "contacting the server" notice before network commands in the browser terminal.'),
    ('v0.1.12', '2026-08-11', 'obj-cas-imm-e8ceb3fc2239',
     'In-browser clone completes: serial-executor shim for Pyodide (WebAssembly cannot spawn threads, so all parallel blob transfers run sequentially in the browser), validated natively against the live server with thread creation disabled: full 225-blob clone of this site vault. A serial/auto-detect mode is proposed upstream to the sgit CLI.'),
    ('v0.1.11', '2026-08-11', 'obj-cas-imm-c9afa76a79cb',
     'Browser-transport fix for in-browser clone: drop the redundant X-API-Key header (the servers CORS-allow x-sgraph-access-token but not x-api-key, and one disallowed header fails the whole preflight, diagnosed live from the first user clone attempt); CORS/network failures now surface as readable HTTP 599 errors instead of a Pyodide SystemError.'),
    ('v0.1.10', '2026-08-11', 'obj-cas-imm-deac6363bff9',
     'In-browser terminal on /try: the real sgit CLI in-process (init, commit, status, history, and network commands via a browser XHR transport: clone/push/pull straight to the SG/Send servers), plus a busybox of file commands over the in-memory filesystem. Key hygiene: every example key on the site is now an obviously-invalid placeholder (format-valid example keys are squattable namespaces), enforced by a new validator rule.'),
    ('v0.1.9', '2026-08-11', 'obj-cas-imm-83b8c23a1baa',
     '"Try sgit in your browser" page: the real sgit-ai wheel running client-side under Pyodide (verified in headless Chromium first), key derivation, encrypt/decrypt, an in-memory vault round trip, and a Python REPL. Plus: bootstrap fast-path for static hosting (no 2.5s bridge wait on GitHub Pages), CNAME for sgit.ai, and GitHub Pages deployment via Actions in the SGit-AI__Website repo.'),
    ('v0.1.8', '2026-08-11', 'obj-cas-imm-775783f31110',
     'Skills promoted to a top-level nav item with a new /skills page and the three agent skills shipped in the vault (latest versions, verbatim); llms.txt added at the vault root, encrypted for key-holders today, a standard public llms.txt when the site deploys to GitHub Pages; Home nav link retired (the brand mark covers it).'),
    ('v0.1.7', '2026-08-11', 'obj-cas-imm-c226a3aff160',
     'SG/Vault section rebuilt from the official docs bundle: corrected security-page structure-key claim to current reality, git page aligned with the publishing guide (three-rule boundary, leak audit, GitHub round trip, restore drill), new pages for static hosting on GitHub Pages, sub-vaults, and no-code content authoring; .gitignore extended with work/ and *.pem rules.'),
    ('v0.1.6', '2026-08-11', 'obj-cas-imm-f62d9bd2d0d4',
     'Corrected git integration to the side-by-side design: git now tracks the encrypted .sg_vault store (only the plaintext local/ folder is excluded), so a git remote doubles as a zero-knowledge mirror of the vault. Pattern documented on the git-and-vaults page.'),
    ('v0.1.5', '2026-08-11', 'obj-cas-imm-353038cc3e56',
     'Git integration: .gitignore (keeps .sg_vault/, above all local/ keys and token, plus git metadata and build noise out of git and out of vault snapshots) and .gitattributes (raw sgit store files marked binary/-diff/-merge; generated *.html marked linguist-generated).'),
    ('v0.1.4', '2026-08-11', 'obj-cas-imm-90254d9591ff',
     'New SG/Vault section, for now the official sgraph platform documentation: SG/Vault & the platform, building vault apps, the window.sg bridge & host capabilities, and "git repos inside vaults" (engineering preview of the pure-Python git reader, verified against a real 906-commit repo).'),
    ('v0.1.3', '2026-08-11', 'obj-cas-imm-46a8b14d4fca',
     'Beta status (in production use), light theme, admin & engineering section, per-push version badge on every page, SG/Vault & SG/Send now link to sgraph.ai, design-improvements brief for Claude Code.'),
    ('v0.1.2', '2026-08-11', 'obj-cas-imm-c3084abbba8c',
     'Replaced the proposal app with the sgit.ai MVP site: 12 individually-navigable pages, shared CSS/JS loaded through the SG bridge, full-strength keys in all examples.'),
    ('v0.1.1', '2026-08-11', 'obj-cas-imm-7f1fbacf0485',
     'Initial vault app: the positioning & messaging proposal microsite with an embedded landing-page prototype.'),
]

# A literal table tag in a note lands inside the version table's own cell and derails the
# markdown emitter with an opaque IndexError three files away, <tr> opens a row, then the
# text after it has no cell to go in. Learned the hard way in v0.2.57; now it fails here,
# by name, at the line that caused it.
for _v, _d, _c, _note in VERSION_LOG:
    for _bad in ('<tr', '<td', '<th', '<table'):
        assert _bad not in _note, (f'VERSION_LOG {_v}: write {_bad!r} as prose, not markup, it breaks the .md twin')

ROOT = find_vault_root()
ADMIN = os.path.join(ROOT, 'admin')

BOOT = """<script>
(function(){var R=document.documentElement.getAttribute('data-root')||'';var V='?v=' + SITE_VERSION_TOKEN;var C=[R,'','../','/'].filter(function(v,i,a){return a.indexOf(v)===i});
function wait(ms){return new Promise(function(res){var t=Date.now();(function p(){if(window.sg)return res(window.sg);if(Date.now()-t>ms)return res(null);setTimeout(p,60)})()})}
function grab(sg,p){return new Promise(function(res){(async function(){if(sg&&sg.vfs&&sg.vfs.readText){try{var t=await sg.vfs.readText(p);if(t)return res(t)}catch(e){}}try{var r=await fetch(p+V);if(r.ok)return res(await r.text())}catch(e){}res(null)})()})}
async function css(sg){for(var i=0;i<C.length;i++){var p=C[i]+'assets/site.css';if(sg&&sg.loadCss){try{await sg.loadCss(p);return}catch(e){}}var t=await grab(sg,p);if(t){var s=document.createElement('style');s.textContent=t;document.head.appendChild(s);return}}}
async function js(sg,name){name=name||'assets/site.js';for(var i=0;i<C.length;i++){var p=C[i]+name;if(sg&&sg.loadJs){try{await sg.loadJs(p);return}catch(e){}}var t=await grab(sg,p);if(t){try{(0,eval)(t)}catch(e){console.error('[site] js failed',name,e)}return}}}
async function boot(){var inVault=false;try{inVault=(window!==window.parent)||location.protocol==='blob:'}catch(e){inVault=true}
var sg=inVault?await wait(2500):null;window.__sgitBoot={roots:C,grab:function(p){return grab(sg,p)},version:V};await css(sg);await js(sg);document.documentElement.classList.add('ready');js(sg,'assets/site-chat.js');try{window.parent&&window.parent.postMessage({type:'sg-app-ready'},'*')}catch(e){}}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot);else boot();
var mine=V.slice(3),last=0;
function fresh(){if(location.hostname!=='sgit.ai'||Date.now()-last<60000)return;last=Date.now();
fetch('/version.txt?t='+Date.now(),{cache:'no-store'}).then(function(r){return r.ok?r.text():''}).then(function(t){t=(t||'').trim();if(!/^v[0-9]+[.][0-9]+[.][0-9]+$/.test(t)||t===mine)return;
var k='sgit.fresh:'+location.pathname;try{if(sessionStorage.getItem(k)===t)return;sessionStorage.setItem(k,t)}catch(e){}
var u=new URL(location.href);u.searchParams.set('v',t);location.replace(u.href)}).catch(function(){})}
try{var u=new URL(location.href);if(u.searchParams.get('v')===mine){u.searchParams.delete('v');history.replaceState(history.state,'',u.pathname+u.search+u.hash)}}catch(e){}
fresh();addEventListener('pageshow',function(e){if(e.persisted){last=0;fresh()}});document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible')fresh()});
})();
</script>"""

# The freshness check above: GitHub Pages serves every page with max-age=600 and the site cannot
# change that, and Safari on an iPad will show a tab from memory well after a release. So the
# page asks /version.txt (no-store) which release is live; if it is not the one the page was built
# in, the page reloads once under ?v=<live>, a URL no cache has seen, and then drops the parameter.
# Only on sgit.ai itself, never in the vault host or a local preview, and once per version per page.

BOOT = BOOT.replace('SITE_VERSION_TOKEN', "'" + SITE_VERSION + "'")

# The fade-in hides the unstyled flash while site.css is bridge-loaded. It must never be able
# to leave the page invisible: a crawler that applies CSS but does not run our bootstrap would
# otherwise see a fully hidden body, invisible to a reader and a hidden-text signal to an
# indexer. Two failsafes: <noscript> reveals immediately, and a CSS-only animation reveals at
# 1.6s regardless of why the bootstrap never arrived.
CRITICAL = ("<style>html{background:#faf9f5}body{margin:0;background:#faf9f5;color:#1c1d21;"
            "font-family:ui-sans-serif,system-ui,sans-serif;opacity:0;transition:opacity .25s;"
            "animation:sg-reveal 0s linear 1.6s forwards}"
            "html.ready body{opacity:1;animation:none}"
            "@keyframes sg-reveal{to{opacity:1}}</style>"
            "<noscript><style>body{opacity:1;animation:none}</style></noscript>")

# Two levels, because one was failing. The flat nav reached FOURTEEN items and wrapped
# to three rows on an iPhone before the page content began, measured on a real
# screenshot, not guessed. Grouping is by what a reader is trying to do, and every
# group's own label is a real link, so nothing is reachable only by hovering.
# The network chooser. One line per question a reader actually arrives with, mapped to
# the site that takes it seriously, because at nineteen siblings the useful question is
# not "what exists" but "which of these is mine".
ASK = [
 ('I need to give an AI agent an identity', 'nhi.sgit.ai',
  'agents you run vs agents you rent, and why only one is answered'),
 ('My app has to call an LLM and I do not want it holding an API key', 'llms.sgit.ai',
  'the bridge that lets it call one without a credential'),
 ('I have to sign off a risk and I do not want to rubber-stamp it', 'risks.sgit.ai',
  'there is no deny button, only how long you accept it'),
 ('I need to cite a regulation precisely, not paraphrase it', 'standards.sgit.ai',
  'point at the provision, or you are asserting'),
 ('I want to distribute public keys without a central authority', 'pki.sgit.ai',
  'a key registry for agents, designed from a directory that was destroyed'),
 ('I am drawing a graph and want to get the edges right', 'graphs.sgit.ai',
  'five rules, and why relates-to is banned'),
 ('I want an issue tracker with no database', 'issues-fs.sgit.ai',
  'the issues are files and the files are a graph'),
 ('I need somewhere disposable to run an agent', 'sg-compute.sgit.ai',
  'ephemeral AWS environments, one command away'),
 ('I am deciding how to license and sustain an open-source project', 'open-source.sgit.ai',
  'open source is a strategy, not a charity'),
 ('I keep being asked what a digital twin actually is', 'twins.sgit.ai',
  'an interface to reality, not a simulation of it'),
 ('I want to protect an app at the edge without renting a WAF', 'sg-sentinel.sgit.ai',
  'an app-coupled edge guard, published as a design, not built'),
 ('I want to map a strategy without drawing a pretty picture', 'wardley-maps.sgit.ai',
  'maps are claims, not pictures'),
 ('I need the requirements nobody writes down until they break', 'nfrs.sgit.ai',
  'resilience, budgets and backups, from the inside'),
 ('I want to know how this code is actually written', 'coding.sgit.ai',
  'the style guide that measured itself'),
 ('I am pricing something and rent feels wrong', 'subscriptions.sgit.ai',
  'a subscription is a discount for regular use, not rent'),
 ('I care where a published fact came from', 'newsroom.sgit.ai',
  'the story is a graph; the article is a projection'),
 ('I just want the picture', 'infographics.sgit.ai',
  'every rendered brief in one catalogue'),
]


NAV = [
    # Articles first (v0.6.85). They are where most of what this site argues lives, and until
    # this release they sat second under Updates, the least-read section. The group is owned
    # by the newsroom (admin/content/newsroom/): the front page, the desk, the collections.
    # Labelled Newsroom from v0.6.89: the SGit Newsroom is the front end of the back office.
    ('articles', 'Newsroom', 'articles/index.html', [
        ('articles', 'Front page', 'articles/index.html'),
        ('newsletter', 'Newsletter', 'articles/newsletter/index.html'),
        ('all', 'Every article', 'articles/index.html#all'),
        ('collections', 'Collections', 'articles/collections/index.html'),
        ('desk', 'From the desk', 'articles/desk/index.html'),
        ('graphs', 'As graphs', 'articles/graphs.html'),
        ('newsroom', 'How it runs', 'newsroom/index.html'),
        ('subscribe', 'Subscribe', 'subscribe/index.html'),
        ('yours', 'Your newsroom', 'account/newsroom.html'),
        ('share', 'A newsroom designed for you', 'account/share.html'),
        ('account', 'Your reading account', 'account/index.html'),
    ]),
    ('why', 'Why', 'why/index.html', [
        ('why', 'Why sgit exists', 'why/index.html'),
        ('investors', 'Investors', 'investors/index.html'),
        ('startups', 'Startups', 'startups/index.html'),
        ('startups', 'Business plans', 'startups/business-plans.html'),
        ('about', 'About the author', 'about/index.html'),
        ('partnerships', 'Partnerships', 'partnerships/index.html'),
    ]),
    ('docs', 'Docs', 'docs/index.html', [
        ('docs', 'Documentation', 'docs/index.html'),
        ('docs', 'Vault guidance', 'docs/guidance/index.html'),
        ('docs', 'Every repository', 'docs/guidance/repositories.html'),
        ('try', 'Try it in the browser', 'try/index.html'),
        ('api', 'HTTP API', 'api/index.html'),
        ('docs', 'Credentials', 'docs/credentials.html'),
        ('vault', 'SG/Vault', 'docs/vault/index.html'),
        ('deploy', 'Deploy', 'deploy/index.html'),
        ('meter', 'SG Meter', 'meter/index.html'),
        ('skills', 'Skills', 'skills/index.html'),
        ('briefs', 'Briefs', 'docs/briefs/index.html'),
    ]),
    ('vaults', 'Vaults', 'demos/vaults/index.html', [
        ('vaults', 'Published vaults', 'demos/vaults/index.html'),
        ('catalogue', 'Catalogue', 'catalogue/index.html'),
        ('demos', 'Demos', 'demos/index.html'),
        ('fractal', 'Fractal graphs', 'demos/fractal-graphs/index.html'),
        ('fractal', 'Performance & cost', 'demos/fractal-graphs/performance.html'),
    ]),
    ('agents', 'Agents', 'agents/index.html', []),
    ('evidence', 'Evidence', 'compare/index.html', [
        ('compare', 'Comparisons', 'compare/index.html'),
        ('fractal', 'Performance & cost', 'demos/fractal-graphs/performance.html'),
        ('case-studies', 'Case studies', 'case-studies/index.html'),
        ('lessons', 'Lessons learned', 'lessons/index.html'),
        ('use-cases', 'Use cases', 'use-cases/index.html'),
    ]),
    ('team', 'Team', 'team/index.html', [
        ('team', 'How the site is run', 'team/index.html'),
        ('roles', 'Roles', 'team/index.html#roles'),
        ('prompts', 'Starting prompts', 'team/prompts.html'),
        ('board', 'The board', 'team/board.html'),
        ('updates', 'Updates', 'updates/index.html'),
        ('admin', 'Version log', 'admin/versions.html'),
    ]),
    ('network', 'Network', 'network/index.html', [
        ('network', 'All sites', 'network/index.html'),
        ('nhi', 'nhi', 'network/nhi.html'),
        ('pki', 'pki', 'network/pki.html'),
        ('graphs', 'graphs', 'network/graphs.html'),
        ('sg-sentinel', 'sg-sentinel', 'network/sg-sentinel.html'),
    ]),
    ('security', 'Security', 'security/index.html', []),
]


def nav(p, here):
    items = []
    for key, label, href, children in NAV:
        keys = {key} | {c[0] for c in children}
        on = ' here' if here in keys else ''
        if not children:
            items.append(f' <div class="ni"><a class="nl{on}" href="{p}{href}">{label}</a></div>')
            continue
        subs = '\n'.join(f' <a class="sl{" here" if here == ck else ""}" href="{p}{chref}">{clabel}</a>'
            for ck, clabel, chref in children)
        items.append(f' <div class="ni ni-has">\n'
            f' <a class="nl{on}" href="{p}{href}">{label}<span class="caret">&#9662;</span></a>\n'
            f' <div class="sub">\n{subs}\n </div>\n'
            f' </div>')
    return f"""<nav class="site"><div class="row">
  <a class="brand" href="{p}index.html">sgit<span>.ai</span></a>
  <span class="stage-pill">beta</span>
  <a class="ver" href="{p}admin/versions.html" title="Site release history">{SITE_VERSION}</a>
  <sg-meter class="meter-nav" view="balance"></sg-meter>
  <button class="nav-toggle" type="button" aria-expanded="false" aria-label="Menu">Menu</button>
  <div class="nav-items">
{chr(10).join(items)}
  </div>
  <a class="gh" href="https://github.com/SGit-AI/SGit-AI__CLI">&#9733; GitHub</a>
</div></nav>"""

def footer(p, md=''):
    return f"""<footer class="site"><div class="cols">
  <div>
    <div class="brandline">sgit<span>.ai</span></div>
    <p>sgit is git for encrypted vaults, clone, commit, branch and merge files that are encrypted with AES-256-GCM before they leave your machine. Open source, Apache-2.0, in beta and powering production workflows.</p>
    <p class="vaultnote">🔒 Thirty vaults on this site open in your browser with published read keys, every byte decrypted client-side, none of it readable by the server that stores it. The pages that describe them are ordinary static files. The medium is the message.</p>
    <p class="verline">site <a href="{p}admin/versions.html">{SITE_VERSION}</a> · <a href="{p}admin/index.html">engineering</a> · <a href="{md}" title="The same page as plain markdown, for agents, and for reading without the styling">this page as markdown</a></p>
  </div>
  <div>
    <h4>Docs</h4>
    <a href="{p}docs/what-is-sgit.html">What is sgit</a>
    <a href="{p}docs/guidance/index.html">Working on a vault: start here</a>
    <a href="{p}docs/surfaces.html">Three surfaces</a>
    <a href="{p}docs/installation.html">Installation</a>
    <a href="{p}docs/quickstart.html">Quickstart</a>
    <a href="{p}docs/sgit-for-git-users.html">sgit for git users</a>
    <a href="{p}docs/agents.html">Working with AI agents</a>
    <a href="{p}docs/limitations.html">When NOT to use sgit</a>
  </div>
  <div>
    <h4>SG/Vault platform</h4>
    <a href="{p}docs/vault/index.html">SG/Vault &amp; the platform</a>
    <a href="{p}docs/vault/vault-apps.html">Building vault apps</a>
    <a href="{p}docs/vault/sg-bridge.html">The window.sg bridge</a>
    <a href="{p}docs/vault/content-authoring.html">Content authoring</a>
    <a href="{p}docs/vault/sub-vaults.html">Sub-vaults</a>
    <a href="{p}docs/vault/git-and-vaults.html">Git repos inside vaults</a>
    <a href="{p}docs/vault/reading-a-vault-file.html">Reading a vault file</a>
    <a href="{p}docs/vault/static-hosting.html">Static hosting</a>
    <a href="https://sgraph.ai">More at sgraph.ai</a>
  </div>
  <div>
    <h4>Project</h4>
    <a href="https://github.com/SGit-AI/SGit-AI__CLI">GitHub</a>
    <a href="{p}security/index.html">Security</a>
    <a href="{p}why/index.html">Why does this exist?</a>
    <a href="{p}use-cases/index.html">Use cases</a>
    <a href="{p}case-studies/index.html">Case studies</a>
    <a href="{p}skills/index.html">Skills for AI agents</a>
    <a href="{p}docs/briefs/index.html">Cross-team briefs</a>
    <a href="{p}articles/index.html#subscribe">Subscribe to new articles</a>
    <a href="{p}llms.txt">llms.txt</a>
    <a href="{p}llms-full.txt">llms-full.txt</a>
    <a href="{p}admin/index.html">Admin &amp; engineering</a>
    <a href="{p}admin/versions.html">Release history</a>
  </div>
</div></footer>"""

# ---------------------------------------------------------------- structured data
# Google's 2026 AI-optimisation guidance is explicit that structured data is not required
# to appear in generative-AI features, "it is still SEO". It is included here for a
# different and specific reason: "sgit" is an ambiguous string. It also names an Android
# Git client, an Indian engineering college, and a class of shell shortcut, and Google's
# AI Overview for the bare query currently disambiguates between them while sourcing this
# project from PyPI. sameAs links the site to the identifiers that already rank, which is
# how an entity gets resolved to the right thing rather than to the most popular thing.

# Filled once the articles are loaded: path -> (author, url). json_ld reads it at call
# time, so an article with an author gets a schema.org Person and the rest do not.
ARTICLE_AUTHORS = {}
# Same shape for licences: path -> licence URL, from the article's `license:` front matter.
# json_ld and the <head> both read it, so the notice printed in the body, the JSON-LD
# license and <link rel="license"> all come from one line in one file.
ARTICLE_LICENSES = {}


def license_link(path):
    lic = ARTICLE_LICENSES.get(path)
    return f'<link rel="license" href="{lic}">\n' if lic else ''


def json_ld(path, title, desc):
    site = {
        "@context": "https://schema.org",
        "@type": "SoftwareApplication",
        "name": "sgit",
        "alternateName": ["sgit-ai", "sgit CLI"],
        "applicationCategory": "DeveloperApplication",
        "applicationSubCategory": "Version control",
        "operatingSystem": "macOS, Linux, Windows",
        "url": "https://sgit.ai",
        "downloadUrl": "https://pypi.org/project/sgit-ai/",
        "installUrl": "https://pypi.org/project/sgit-ai/",
        "softwareHelp": "https://sgit.ai/docs/",
        "license": "https://www.apache.org/licenses/LICENSE-2.0",
        "programmingLanguage": "Python",
        "softwareRequirements": "Python >= 3.11",
        "description": ("git for encrypted vaults: clone, commit, branch and merge files that are "
                        "encrypted client-side with AES-256-GCM before they leave your machine. The "
                        "server stores ciphertext under opaque ids and never sees filenames, contents "
                        "or commit messages."),
        "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
        "sameAs": ["https://pypi.org/project/sgit-ai/",
                   "https://github.com/SGit-AI/SGit-AI__CLI",
                   "https://github.com/SGit-AI/SGit-AI__Website"],
        "disambiguatingDescription": ("Distinct from SGit, the Android Git client, and from SGIT, "
                                      "the Dr. Samuel George Institute of Engineering and Technology. "
                                      "This is the encrypted-vault CLI published to PyPI as sgit-ai."),
    }
    page_node = {"@context": "https://schema.org",
                 "@type": "TechArticle" if path != 'index.html' else "WebSite",
                 "name": title, "headline": title, "description": desc,
                 "url": f"https://sgit.ai/{path}",
                 "inLanguage": "en",
                 "isPartOf": {"@type": "WebSite", "name": "sgit.ai", "url": "https://sgit.ai"},
                 "about": {"@type": "SoftwareApplication", "name": "sgit",
                           "sameAs": "https://pypi.org/project/sgit-ai/"}}
    au = ARTICLE_AUTHORS.get(path)
    if au:
        u = au[1] if au[1].startswith('http') else f"https://sgit.ai/{au[1]}"
        page_node["author"] = {"@type": "Person", "name": au[0], "url": u}
    if ARTICLE_LICENSES.get(path):
        page_node["license"] = ARTICLE_LICENSES[path]
    blocks = [site, page_node] if path == 'index.html' else [page_node]
    return '\n'.join('<script type="application/ld+json">' + json.dumps(b, ensure_ascii=False)
                      + '</script>' for b in blocks)


# ---------- link previews ----------
# og:image was simply absent, so every shared link rendered as a title-and-domain card
# with no picture. Two things had to be true to fix it. The URL must be ABSOLUTE, and
# the file must not be WebP: LinkedIn's crawler does not read WebP and drops the image
# without complaint, which is the trap, because the page looks correct and the preview
# is silently plain. admin/build/make_og_cards.mjs writes a 1200x630 JPEG per article
# hero plus one default, and this picks the right one. validate.js checks the file it
# names actually exists, so forgetting to run the generator fails the build.
def og_card(path):
    # Safe to fall through to the default here: the ARTICLES check above has already
    # refused the build if an article with a hero is missing its card, so the only
    # pages that reach the fallback are the ones that are meant to.
    if path.startswith('articles/') and path.endswith('.html'):
        slug = os.path.basename(path)[:-5]
        if os.path.exists(os.path.join(ROOT, 'og', slug + '.jpg')):
            return 'og/' + slug + '.jpg'
    return 'og/default.jpg'

_TRAILS = None
def expand_trails(path, body):
    """Replace each <!-- trail:<name> --> with its row of cards, paths relative to this page,
    the card for this page marked. A trail is one data file, so a new companion piece is one
    line in admin/content/trails.json and every page that carries the trail shows it."""
    global _TRAILS
    if '<!-- trail:' not in body:
        return body
    if _TRAILS is None:
        with open(os.path.join(ROOT, 'admin', 'content', 'trails.json')) as f:
            _TRAILS = json.load(f)
    p = '../' * path.count('/')
    rel = lambda h: h if h.startswith('http') else p + h.lstrip('/')
    def render(m):
        name = m.group(1)
        if name not in _TRAILS:
            raise SystemExit(f'{path}: unknown trail {name!r}')
        t = _TRAILS[name]; cards = []
        for it in t['items']:
            here = not it.get('external') and it['href'].lstrip('/').split('#')[0] == path
            ext = ' target="_blank" rel="noopener"' if it.get('external') else ''
            # The authoring contract forbids a declarative <img src>; shots.js fills this placeholder.
            img = f'<span class="trail-img" data-trail-img="{html.escape(rel(it["image"]), quote=True)}" role="img" aria-label=""></span>' if it.get('image') else ''
            body_ = (f'<span class="trail-k">{html.escape(it["label"])}</span>'
                     f'<span class="trail-t">{html.escape(it["title"])}{" &#8599;" if it.get("external") else ""}</span>'
                     f'<span class="trail-d">{html.escape(it["line"])}</span>')
            badge = '<span class="trail-here">you are here</span>' if here else ''
            if it.get('links'):
                links = ''.join(f'<a href="{html.escape(rel(l["href"]), quote=True)}"'
                                + (' target="_blank" rel="noopener"' if l.get('external') else '')
                                + f'>{html.escape(l["text"])}{" &#8599;" if l.get("external") else ""}</a>' for l in it['links'])
                cards.append(f'<div class="trail-c{" here" if here else ""}">{badge}<a href="{html.escape(rel(it["href"]), quote=True)}">{img}</a>'
                             f'<div class="trail-b">{body_}<span class="trail-l">{links}</span></div></div>')
            else:
                cards.append(f'<a class="trail-c{" here" if here else ""}" href="{html.escape(rel(it["href"]), quote=True)}"{ext}>{badge}{img}'
                             f'<span class="trail-b">{body_}</span></a>')
        return (f'<div class="trail"><div class="trail-h">{html.escape(t["title"])}</div>'
                f'<p class="trail-n">{html.escape(t.get("note", ""))}</p><div class="trail-row">{"".join(cards)}</div></div>')
    return re.sub(r'<!-- trail:([a-z0-9-]+) -->', render, body)


# ============================================================ the reading meter
# One set of rules, used three ways: injected into every page for assets/meter.js to charge by,
# printed as the price table on the account page, and described in the article about it.
# Pence. A new article is one published in the last `new_days` days, judged in the reader's
# browser from the article's date, so prices age without a rebuild.
METER = {
    'currency': 'GBP', 'start': 500, 'new_days': 7,
    # v0.7.26: the go-live hypothesis. A page is charged for the share of it the reader scrolled
    # through, so these are the prices of a page read to the end. Credit may go negative.
    'prices': {'article_new': 10, 'article': 5, 'issue': 3, 'note': 2, 'collection': 2, 'page': 1, 'free': 0},
    # A Stripe Payment Link whose confirmation redirects to
    # https://sgit.ai/account/topped-up.html?session_id={CHECKOUT_SESSION_ID}. Empty: the simulated cart.
    'topup': {'amount': 500, 'link': ''},
    'packs': [{'id': 'p2', 'price': 200, 'bonus': 0}, {'id': 'p5', 'price': 500, 'bonus': 0},
              {'id': 'p10', 'price': 1000, 'bonus': 100}, {'id': 'p20', 'price': 2000, 'bonus': 300}],
}
METER_LABEL = [('article_new', 'An article published in the last seven days'), ('article', 'An older article'),
               ('issue', 'A newsletter issue'), ('note', 'A desk note'), ('collection', 'A collection'),
               ('page', 'Any other page: docs, vaults, the network, the newsroom'),
               ('free', 'The homepage, the subscribe page and your account')]
METER_FREE = ('index.html', 'account/', 'subscribe/')


def meter_kind(path):
    """(kind, date, topics) for the meter: what this page is, for pricing and for the history
    the personalisation reads."""
    if path == 'index.html' or path.startswith(METER_FREE[1:]):
        return 'free', '', []
    m = re.match(r'^articles/([a-z0-9-]+)\.html$', path)
    if m and m.group(1) in BY_SLUG:
        a = BY_SLUG[m.group(1)]
        return 'article', a['date'], art_topics(a)
    if path.startswith('articles/newsletter/') and not path.endswith('index.html'):
        return 'issue', '', []
    if path.startswith('articles/desk/') and not path.endswith('index.html'):
        return 'note', '', []
    if path.startswith('articles/collections/') and not path.endswith('index.html'):
        return 'collection', '', []
    return 'page', '', []


METER_JS = 'assets/components/sg-meter/v1/v1.3/v1.3.1/sg-meter.js'

# Five personas a reader can start from (SG Meter v1.1), each a handful of topics and articles
# chosen from what this site has actually published. A persona is a way to manage focus: the
# same reader reads differently as a founder and as a security lead. The names are made up on
# purpose and gender-neutral; a reader can rename any persona. The build refuses a slug that
# is not an article, so a preset cannot point at nothing.
PERSONAS = [
    {'id': 'founder', 'label': 'The founder', 'name': 'Morgan', 'theme': '#b45309',
     'blurb': 'Building a company: what to build, what to charge, what to secure and when to stop. Strategy, pricing and the questions investors ask.',
     'topics': {'startups-and-strategy': 3, 'agents-and-policy': 1},
     'articles': ['the-question-is-whether-they-miss-it', 'price-it-then-give-it-away', 'saas-apocalypse-decided-by-inertia-not-by-ai',
                  'who-are-you-protecting-against', 'knowing-when-to-stop', 'open-source-is-not-free', 'going-live-with-the-reading-meter']},
    {'id': 'journalist', 'label': 'The journalist', 'name': 'Rowan', 'theme': '#1d4ed8',
     'blurb': 'Where news came from and where it could go: story vaults, liquid content, local evidence, and what publishers are owed by the machines that read them.',
     'topics': {'news-and-evidence': 3, 'graphs-and-knowledge': 1},
     'articles': ['how-news-got-here', 'future-of-news-story-vault-not-paywall', 'liquid-content-needs-water', 'the-bridge-followed-to-the-end',
                  'story-vault-meets-reader-skills', 'the-waiting-room-knew-first', 'token-bill-nobody-is-sending']},
    {'id': 'security', 'label': 'The security lead', 'name': 'Sam', 'theme': '#b91c1c',
     'blurb': 'Agents as insiders: who you are protecting against, blast radius, identity, incidents and the controls that hold when a model does not.',
     'topics': {'agents-and-policy': 3, 'vaults-and-method': 1},
     'articles': ['who-are-you-protecting-against', 'ultimate-insider-three-collisions', 'footprint-and-blast-radius', 'six-agents-one-inbox',
                  'the-investigation-github-owes-its-customers', 'why-my-agents-do-not-run-on-my-laptop', 'the-identity-we-wanted-to-give-the-agents',
                  'where-is-the-why']},
    {'id': 'builder', 'label': 'The AI builder', 'name': 'Kai', 'theme': '#0f766e',
     'blurb': 'Making agents work day to day: Claude Code sessions, hooks, memory, behaviour policies, and the engineering lessons each mistake left behind.',
     'topics': {'agents-and-policy': 2, 'site-and-engineering': 2, 'vaults-and-method': 1},
     'articles': ['how-i-work-with-claude', 'a-second-reader-the-agent-cannot-skip', 're-anchoring-agent-behaviour-policies', 'every-mistake-added-a-rule',
                  'memory-is-not-a-spectator-sport', 'encrypted-memory-for-isolated-agents', 'hope-or-enforcement']},
    {'id': 'board', 'label': 'The board member', 'name': 'Jordan', 'theme': '#6d28d9',
     'blurb': 'How AI is changing the business and how to govern it: business models, due diligence, risk acceptance, accountability and the governance frameworks.',
     'topics': {'graphs-and-knowledge': 2, 'startups-and-strategy': 2, 'agents-and-policy': 1},
     'articles': ['saas-apocalypse-decided-by-inertia-not-by-ai', 'send-an-agent-not-a-spreadsheet', 'every-risk-is-already-accepted',
                  'agency-is-not-a-yes', 'the-ai-governance-stack-as-a-graph', 'ai-baseline-control-framework', 'what-sgit-is']},
]
# What a persona built from reading is called, after the topic it reads most.
PERSONA_NAMES = {'agents-and-policy': 'The Policy Architect', 'vaults-and-method': 'The Vault Keeper',
                 'news-and-evidence': 'The Fact Finder', 'startups-and-strategy': 'The Strategist',
                 'graphs-and-knowledge': 'The Cartographer', 'site-and-engineering': 'The Builder'}


def meter_config():
    """The METER rules in the shape the component reads (see /meter/ for every key). The storage
    key is the one v0.7.24 used, so a reader's balance and history carry over."""
    bad = [(p['id'], x) for p in PERSONAS for x in p['articles'] if x not in BY_SLUG]
    if bad:
        raise SystemExit(f'PERSONAS name articles that do not exist: {bad}')
    return json.dumps({
        'storageKey': 'sgit.meter.v1', 'start': METER['start'], 'newDays': METER['new_days'], 'symbol': '£',
        'prices': METER['prices'], 'depth': True, 'minDepth': 0.1,
        'topup': METER['topup'], 'packs': METER['packs'],
        'links': {'account': 'account/index.html', 'topup': 'account/top-up.html',
                  'how': 'articles/going-live-with-the-reading-meter.html'},
        # v1.3: usefulness 1..5 multiplies the price (1 free, 3 the price, 5 double); "more like
        # this" moves the page's topics and tags in the active persona. Rated in the middle by default.
        'rating': {'price': [0, 0.5, 1, 1.5, 2], 'more': [-3, -1, 0, 3, 6]}, 'autoKeep': True,
        'picks': {'feed': 'articles/graphs.json', 'base': 'articles/'},
        'links2': {'newsroom': 'account/newsroom.html', 'personas': 'account/personas.html', 'share': 'account/share.html'},
        # v1.2: a reader can send their reading, encrypted in the browser, into the subscribe vault's
        # write-only lane, marked X-SGit-Form: reading-share so the list agent files it apart from
        # subscriptions (docs/briefs/subscribe-lane-agent-brief.html#reading-share).
        'share': {'contact': '.well-known/sgit-subscribe.json', 'form': 'reading-share', 'site': 'sgit.ai', 'max': 300},
        'personas': {'presets': PERSONAS, 'names': PERSONA_NAMES},
    }, ensure_ascii=False).replace('</', '<\\/')


def page(path, title, desc, here, body):
    # Root prefix by DEPTH, not by "is nested at all", pages now nest three deep
    # (demos/vaults/<slug>/index.html) and a single '../' silently pointed the nav,
    # the stylesheet and every asset at the wrong level. Same formula write_md uses.
    body = expand_trails(path, body)
    p = '../' * path.count('/')
    md_name = os.path.basename(path)[:-5] + '.md'
    # Same cache-busting as the bootstrap, applied to the components a page body
    # fetches for itself. Done here so no content file has to remember it.
    body = re.sub(r"(assets/[a-z-]+\.js)'", r"\1?v=" + SITE_VERSION + "'", body)
    # On a vault page, the first screenshot is a picture of something the reader can open:
    # the build marks it with the page's own "open in the official UI" link, and shots.js
    # turns the image into that link. Asked for by the founder on 27 September (v0.6.14):
    # the natural next step from the picture should be the real thing.
    if path.startswith('demos/vaults/') and path != 'demos/vaults/index.html' and 'data-href=' not in body:
        m_url = re.search(r'href="(https://dev\.vault\.sgraph\.ai/[^"]*#[^"]+)"', body)
        m_fig = re.search(r'<figure class="shot[^"]*"(?![^>]*data-href)[^>]*data-shot="[^"]+"[^>]*>', body)
        if m_url and m_fig:
            fig_open = m_fig.group(0)
            marked = fig_open[:-1] + f' data-href="{m_url.group(1)}" data-href-label="Open this vault in a new tab">'
            end = body.find('</figure>', m_fig.end())
            block = body[m_fig.end():end]
            tail = ' <b>Click the image to open the real one &#8599;</b>'
            if '</figcaption>' in block:
                block = block.replace('</figcaption>', tail + '</figcaption>', 1)
            else:
                block += f'<figcaption>{tail.strip()}</figcaption>'
            body = body[:m_fig.start()] + marked + block + body[end:]
    # In an article, a figure drawn from a vault page's own image folder is a picture of
    # that vault: the build links it to the vault page, in the same tab, so a reader who
    # sees the screenshot can go on to the real thing (v0.6.53, asked for on 3 October).
    if path.startswith('articles/'):
        def _link_vault_fig(m):
            fig_open, slug = m.group(1), m.group(2)
            if 'data-href=' in fig_open:
                return m.group(0)
            marked = fig_open[:-1] + (f' data-href="../demos/vaults/{slug}/index.html" '
                                      f'data-href-label="Open the vault page" data-href-target="_self">')
            block = m.group(3)
            tail = ' <b>Click the image to open the vault page &#8599;</b>'
            if '</figcaption>' in block:
                block = block.replace('</figcaption>', tail + '</figcaption>', 1)
            else:
                block += f'<figcaption>{tail.strip()}</figcaption>'
            return marked + block + '</figure>'
        body = re.sub(r'(<figure class="shot[^"]*"[^>]*data-dir="\.\./demos/vaults/([a-z0-9-]+)/images/"[^>]*>)(.*?)</figure>',
                      _link_vault_fig, body, flags=re.S)
    # A page with walkthrough figures needs the component that fills them. Markdown
    # content types emit those figures from a `!shot` line, and a markdown author has
    # no place to put a script tag, so the engine notices and wires it, rather than
    # every content file having to remember. (The views page shipped once with the
    # figures and without the loader: nothing errored, and no images appeared.)
    # Match on data-shot=, the attribute shots.js actually selects on. Matching the
    # class string instead missed `class="shot net-shot"` and silently left an index
    # page with figures and no loader, which is the same failure this block exists to
    # prevent. Detect on the thing the consumer looks for, not on how it was written.
    # v0.6.13: the guard looked for the literal 'assets/shots.js', while 33 pages loaded it
    # through a loop that joins '../../../assets/' + f, so both ran. Any mention of shots.js
    # in the body now counts as the page loading it itself, and those pages were changed to
    # leave the loading to this block, which is the single, versioned loader.
    if ('data-shot="' in body or 'data-trail-img=' in body) and 'shots.js' not in body:
        body += (f'\n<script>\n(function () {{\n'
                 f" fetch('{p}assets/shots.js?v={SITE_VERSION}')\n"
                 ' .then(function (r) { if (!r.ok) throw new Error(r.status); return r.text(); })\n'
                 ' .then(function (t) { (0, eval)(t); })\n'
                 " .catch(function (e) { console.error('[shots] component failed to load:', e); });\n"
                 '}());\n</script>')
    if 'data-subscribe' in body and 'subscribe.js' not in body:
        body += (f'\n<script>\n(function () {{\n'
                 f" fetch('{p}assets/subscribe.js?v={SITE_VERSION}')\n"
                 ' .then(function (r) { if (!r.ok) throw new Error(r.status); return r.text(); })\n'
                 ' .then(function (t) { (0, eval)(t); })\n'
                 " .catch(function (e) { console.error('[subscribe] component failed to load:', e); });\n"
                 '}());\n</script>')
    # The reading meter (assets/components/sg-meter, documented at /meter/): every page says
    # what it is in a <sg-meter view="page">, the build's METER rules go in as the component's
    # config, and the component debits a balance kept in the reader's browser by how far down the
    # page they read. On an article the line is shown at the foot of the body; elsewhere it is quiet.
    if '<sg-meter view="page"' not in body:
        kind, date, topics = meter_kind(path)
        quiet = '' if kind in ('article', 'issue', 'note') else ' quiet'
        tag = (f'<sg-meter view="page" kind="{kind}" date="{date}" topics="{" ".join(topics)}" '
               f'title="{_esc(title)}"{quiet}></sg-meter>')
        # at the end of the text: before an article's threads, else at the foot of the main column
        at = '<section class="athreads"' if '<section class="athreads"' in body else '</main>'
        if not quiet and at in body:
            i = body.rindex(at)
            body = body[:i] + tag + '\n' + body[i:]
        else:
            body += '\n' + tag
    body += ('\n<script type="application/json" id="sg-meter-config">' + meter_config() + '</script>\n<script>\n'
             f"import('{p}{METER_JS}?v={SITE_VERSION}')\n"
             " .catch(function (e) { console.error('[sg-meter] component failed to load:', e); });\n</script>")
    card = og_card(path)
    html = f"""<!doctype html>
<html lang="en" data-root="{p}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{title}</title>
<meta name="description" content="{desc}">
<link rel="canonical" href="https://sgit.ai/{path}">
{license_link(path)}<meta property="og:type" content="website">
<meta property="og:site_name" content="sgit.ai">
<meta property="og:url" content="https://sgit.ai/{path}">
<meta property="og:title" content="{title}">
<meta property="og:description" content="{desc}">
<meta property="og:image" content="https://sgit.ai/{card}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{title}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://sgit.ai/{card}">
<link rel="alternate" type="text/markdown" href="{md_name}" title="This page as markdown">
{json_ld(path, title, desc)}
{CRITICAL}
</head>
<body>

{nav(p, here)}

{llms_chip(path)}
{body}

{footer(p, os.path.basename(path)[:-5] + '.md')}

{BOOT}
</body>
</html>
"""
    out = os.path.join(ROOT, path)
    os.makedirs(os.path.dirname(out), exist_ok=True)
    with open(out, 'w') as f:
        f.write(html)
    print('wrote', path, f'({len(html)} bytes)')


REPO_URL = 'https://github.com/SGit-AI/SGit-AI__Website'
VERSION_CHANGES = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'version_changes.json')

def git_lines(*args):
    """Lines from a git command run in the site tree, or None where git or the history is not there."""
    import subprocess
    try:
        r = subprocess.run(['git', '-C', ROOT, *args], capture_output=True, text=True, timeout=30)
    except (OSError, subprocess.SubprocessError):
        return None
    return r.stdout.splitlines() if r.returncode == 0 else None

def version_changes():
    """{version: [[status, source path], ...]}: the content sources each release added (A) or changed (M).
    A release is one commit, so `git show` of its id is its change set; the working tree is the change
    set of 'this release', which is built before it is committed. Released rows are cached in
    version_changes.json beside this file, so each is read from git once and a build without the
    history still links them."""
    try:
        cache = json.load(open(VERSION_CHANGES))
    except (OSError, ValueError):
        cache = {}
    dirty = False
    for v, d, c, note in VERSION_LOG:
        if c.startswith('git ') and v not in cache:
            lines = git_lines('show', '--no-renames', '--name-status', '--format=', c[4:], '--', 'admin/content')
            if lines is not None:
                cache[v] = [l.split('\t', 1) for l in lines if l[:1] in 'AM' and '\t' in l]
                dirty = True
    if dirty:
        with open(VERSION_CHANGES, 'w') as f:
            json.dump(cache, f, indent=1, sort_keys=True); f.write('\n')
    out = dict(cache)
    current = [v for v, d, c, note in VERSION_LOG if c == 'this release']
    lines = git_lines('status', '--porcelain', '--no-renames', '--untracked-files=all', '--', 'admin/content')
    if current and lines:
        out[current[0]] = [['A' if l[:2] in ('??', 'A ') else 'M', l[3:]] for l in lines if 'D' not in l[:2]]
    return out

def changed_pages(changes):
    """Source paths to the published pages they produce, deduplicated, new before updated, as
    (page, title, is_new). articles/graphs/<slug>.json belongs to articles/<slug>.html; images
    and data files are left to the commit link."""
    pages = {}
    for status, src in changes:
        rel = src[len('admin/content/'):] if src.startswith('admin/content/') else src
        m = re.match(r'articles/graphs/([^/]+)\.json$', rel)
        if m:                     page = f'articles/{m.group(1)}.html'
        elif rel.endswith('.md'):   page = rel[:-3] + '.html'
        elif rel.endswith('.html'): page = rel
        else:                     continue
        if not os.path.isfile(os.path.join(ROOT, page)):
            continue
        pages[page] = pages.get(page, False) or (status == 'A' and not m)
    def title(page):
        try:
            t = re.search(r'<title>(.*?)</title>', open(os.path.join(ROOT, page)).read(), re.S).group(1)
            return re.sub(r', sgit\.ai$', '', html.unescape(t).strip())
        except (OSError, AttributeError):
            return page
    return sorted(((p, title(p), n) for p, n in pages.items()), key=lambda x: (not x[2], x[1].lower()))

def version_links(v, c, changes, prev):
    """The commit cell, linked to GitHub, and the line of page links under a release's note."""
    url = (f'{REPO_URL}/commit/{c[4:]}' if c.startswith('git ') else
           f'{REPO_URL}/compare/{prev}...{v}' if c == 'this release' and prev else None)
    cell = f'<a href="{url}">{c}</a>' if url else c
    pages = changed_pages(changes.get(v, []))
    if not pages and not url:
        return cell, ''
    MAX = 8
    shown = pages[:MAX]
    def group(label, items):
        links = ', '.join(f'<a href="../{p}">{html.escape(t)}</a>' for p, t, n in items)
        return f'<span><b>{label}:</b> {links}.</span>' if items else ''
    parts = [group('New', [x for x in shown if x[2]]), group('Updated', [x for x in shown if not x[2]])]
    if len(pages) > MAX:
        parts.append(f'<span>And {len(pages) - MAX} more pages.</span>')
    if url:
        parts.append(f'<span><a href="{url}">Every change, in git</a>.</span>')
    return cell, '<div class="vchg">' + ' '.join(p for p in parts if p) + '</div>'

def versions_body():
    changes = version_changes()
    def row(i, v, d, c, note):
        prev = VERSION_LOG[i + 1][0] if i + 1 < len(VERSION_LOG) else None
        cell, links = version_links(v, c, changes, prev)
        return f' <tr><td class="vnum">{v}</td><td>{d}</td><td class="vid">{cell}</td><td>{note}{links}</td></tr>'
    rows = '\n'.join(row(i, *r) for i, r in enumerate(VERSION_LOG))
    return f"""<main class="doc">
  <p class="crumb"><a href="../index.html">Home</a> / <a href="index.html">Admin</a> / Versions</p>
  <h1>Release history</h1>
  <p class="lead">The site version (<b>{SITE_VERSION}</b>, shown in the nav of every page) increments on every release. Each release is one git commit on the <code>dev</code> branch of <code>SGit-AI/SGit-AI__Website</code>, and the git log is the authoritative audit trail; this page is the human-readable index of it. Each commit id links to that commit on GitHub, and under each note are the pages the release added or changed.</p>
  <div class="tablewrap"><table class="vers">
    <tr><th>Version</th><th>Date</th><th>Commit</th><th>Changes</th></tr>
{rows}
  </table></div>
  <p class="small dim">Numbering: v0.1.n while the site is in its first structure; the middle digit will bump on structural redesigns. The commit of the newest row reads "this release" because the id only exists once the release is committed. It is recorded here retroactively by the next release. Rows up to v0.2.76 carry the id of the sgit commit in the site's own vault, which was pushed alongside git until the mirror was <a href="../case-studies/one-tree-two-remotes.html">retired</a>; from v0.2.77 the id is the git commit, prefixed <code>git</code>. Commit ids before v0.2.84 are those of the history as rewritten on 19 September 2026 to purge the mirror.</p>
  <div class="pagenav"><a href="index.html">← Admin &amp; engineering</a><span></span></div>
</main>"""

# ============================================================ markdown mirror
# Every page is also written as .md next to its .html. This is not a nicety: agents
# are a first-class audience for this site, and asking them to parse styled HTML to
# reach guidance is a tax with no upside. The markdown is generated from the same
# body the HTML is built from, so the two can never drift.
#
# Internal .html links are rewritten to .md so an agent can traverse the whole site
# without ever touching HTML. Anything visual (SVG diagrams, terminal chrome) is
# reduced to its caption or its plain text, the words survive, the styling does not.

class Markdown_Writer(HTMLParser):
    SKIP = {'script', 'style', 'svg', 'button', 'nav', 'footer'}
    SKIP_CLASS = {'capnum'} # decoration whose text says nothing in prose
    VOID = {'br', 'img', 'hr', 'input', 'meta', 'link', 'source'}
    BLOCK = {'p', 'div', 'section', 'article', 'main', 'header', 'ul', 'ol', 'table', 'tr', 'blockquote', 'details'}

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.out = [] # emitted chunks
        self.skipst = [] # tag stack while inside a skipped element
        self.pre = 0 # depth inside <pre>
        self.list = [] # stack of ('ul'|'ol', counter)
        self.row = None # cells of the table row being built
        self.thead = False
        self.cols = 0
        self.href = None
        self.link_text = None
        self.figcap = False

    # ---- helpers
    def emit(self, s):
        if self.skipst: return
        if self.link_text is not None: self.link_text.append(s)
        elif self.row is not None: self.row[-1] += s
        else: self.out.append(s)

    def block(self):
        if self.out and not self.out[-1].endswith('\n\n'):
            self.out.append('\n\n' if self.out[-1].strip() else '\n')

    # ---- tags
    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if self.skipst: # already inside a skipped subtree
            if tag not in self.VOID: self.skipst.append(tag)
            return
        if tag in self.SKIP or a.get('class') in self.SKIP_CLASS:
            self.skipst = [tag]
            return

        if tag == 'pre': self.pre += 1; self.block(); self.emit('```\n')
        elif tag == 'code' and not self.pre: self.emit('`')
        elif tag in ('b', 'strong'): self.emit('**')
        elif tag in ('em', 'i'): self.emit('*')
        elif tag == 'br': self.emit(' \n' if not self.pre else '\n')
        elif tag == 'a':
            self.href = a.get('href'); self.link_text = []
        elif tag in ('h1', 'h2', 'h3', 'h4'):
            self.block(); self.emit('#' * int(tag[1]) + ' ')
        elif tag in ('ul', 'ol'):
            self.block(); self.list.append([tag, 0])
        elif tag == 'li':
            if not self.list: self.list.append(['ul', 0])
            self.list[-1][1] += 1
            kind, n = self.list[-1]
            indent = ' ' * (len(self.list) - 1)
            self.out.append('\n' + indent + (f'{n}. ' if kind == 'ol' else '- '))
        elif tag == 'table':
            self.block(); self.thead = True; self.cols = 0
        elif tag == 'tr':
            self.row = []
        elif tag in ('td', 'th'):
            if self.row is not None: self.row.append('')
        elif tag == 'blockquote':
            self.block(); self.emit('> ')
        elif tag == 'summary':
            self.block(); self.emit('**')
        elif tag in self.BLOCK:
            self.block()
        if a.get('class') == 'figcap': self.figcap = True

    def handle_endtag(self, tag):
        if self.skipst:
            if tag in self.skipst:
                while self.skipst and self.skipst.pop() != tag:
                    pass
            if not self.skipst and tag == 'svg':
                self.out.append('\n\n*[diagram]*')
            return

        if tag == 'pre': self.pre = max(0, self.pre - 1); self.emit('\n```\n\n')
        elif tag == 'code' and not self.pre: self.emit('`')
        elif tag in ('b', 'strong'): self.emit('**')
        elif tag in ('em', 'i'): self.emit('*')
        elif tag == 'a':
            text = ''.join(self.link_text or []).strip()
            href, self.link_text, self.href = self.href, None, None
            if not text: return
            if href and not href.startswith(('http', 'mailto:', '#')):
                href = re.sub(r'\.html(#|$)', r'.md\1', href)
            self.emit(f'[{text}]({href})' if href else text)
        elif tag in ('h1', 'h2', 'h3', 'h4'): self.out.append('\n\n')
        elif tag in ('ul', 'ol'):
            if self.list: self.list.pop()
            self.out.append('\n')
        elif tag == 'tr':
            cells = [' '.join(c.split()) for c in (self.row or [])]
            self.row = None
            if not cells: return
            self.out.append('\n| ' + ' | '.join(cells) + ' |')
            if self.thead:
                self.out.append('\n|' + '---|' * len(cells))
                self.thead = False
        elif tag == 'table': self.out.append('\n\n')
        elif tag == 'summary': self.emit('**\n')
        elif tag in self.BLOCK: self.block()
        self.figcap = False

    def handle_data(self, data):
        if self.skipst: return
        if self.pre:
            self.emit(data)
        else:
            text = re.sub(r'\s+', ' ', data)
            if text.strip() or (self.out and self.out[-1].endswith((')', '`', '*'))):
                self.emit(text)

    def markdown(self):
        md = ''.join(self.out)
        md = md.replace('\u00a0', ' ')
        md = re.sub(r'[ \t]+\n', '\n', md)
        md = re.sub(r'\n{3,}', '\n\n', md)
        md = re.sub(r'\n +([-*] |\d+\.)', r'\n\1', md) # un-indent top-level bullets
        return md.strip() + '\n'


def _subscribe_md(m):
    """The subscribe form, said in words for the markdown twin: a form flattened to markdown runs
    its labels together and shows its 'sent' message as if something had been sent."""
    how = re.search(r'href="([^"]*subscribe-lane-agent-brief\.html)"', m.group(0))
    mail = re.search(r'href="(mailto:[^"]+)"', m.group(0))
    return ('<p><b>Get new articles by email.</b> The HTML version of this page has a form that encrypts '
            'your address in the browser and drops it into a write-only lane on an encrypted vault, read by '
            f'the agent that manages the list (<a href="{how.group(1) if how else ""}">how it works</a>). '
            f'Or email <a href="{mail.group(1) if mail else "mailto:" + SUBSCRIBE_TO}">{SUBSCRIBE_TO}</a> '
            'with the subject "Subscribe: sgit.ai articles".</p>')


def to_markdown(body):
    body = re.sub(r'<aside class="subscribe".*?</aside>', _subscribe_md, body, flags=re.S)
    w = Markdown_Writer()
    w.feed(body)
    return w.markdown()


def write_md(path, title, desc, body):
    body = expand_trails(path, body)
    depth = path.count('/')
    root = '../' * depth
    md = (f'# {title}\n\n> {desc}\n\n'
             f'*Source: <https://sgit.ai/{path}> · site {SITE_VERSION} · '
             f'this file is generated from the same content as the page, so the two cannot drift. '
             f'Every page on this site has a `.md` twin; internal links below point at them.*\n\n---\n\n'
             + to_markdown(body)
             + f'\n\n---\n\n*[Site index for agents]({root}llms.txt) · '
               f'[HTML version](https://sgit.ai/{path})*\n')
    out = os.path.join(ROOT, path[:-5] + '.md')
    os.makedirs(os.path.dirname(out), exist_ok=True)
    with open(out, 'w') as f:
        f.write(md)
    return len(md)



# ============================================================ llms.txt
# Generated from PAGES, so it cannot go stale, the same reason the orphan and
# link checks exist. Every entry points at the .md twin: an agent following this
# index never has to parse HTML.

LLMS_PREAMBLE = """# sgit.ai

> sgit is git for encrypted vaults: clone, commit, branch and merge files that are
> encrypted client-side (AES-256-GCM) before they leave your machine. The server stores
> ciphertext under opaque IDs, it never sees filenames, contents, or commit messages.
> This site is the official documentation for sgit and the SGraph vault platform
> (SG/Vault, SG/Send).

Every page below is markdown, generated from the same source as the HTML page at the
same path (swap `.md` for `.html`). Links inside the markdown point at markdown, so you
can traverse the entire site without parsing HTML.

START HERE, BY WHAT YOU ARE DOING, the page list below is exhaustive and therefore flat,
so these are the entry points that are worth more than their position in it suggests:

- **About to build, publish or change a vault?** /docs/guidance/index.md is the front door:
  the practices that get repeated most, and a route to the page that answers each question.
  Its agent-shaped twin is /docs/guidance/llms.txt, five rules, a reading order, the briefs,
  and reference implementations to go and check.
- **Deciding WHERE your code should run?** /docs/surfaces.md first. `_page.json` inside a
  vault, an HTML vault app, or a page on a *.sgit.ai site are three different answers to
  almost every question, and they have opposite trust directions.
- **Writing a viewer, an app or a page against a vault?** /docs/briefs/, build briefs
  written to be executed, each ending in a prompt to hand a builder agent.
- **Looking for a worked example?** /demos/vaults/llms.txt is the catalogue of every
  published vault with its read key, generated from the same file the human-readable table
  is, so the two cannot disagree.
- **Fractal Semantic Graphs?** /demos/fractal-graphs/index.md defines the term (every node opens
  into a semantic graph with its own ontology, down to the word; only the grammar is shared) and then walks one grammar from
  the text of a law to a threat in one method on one compute instance, across the vault where the idea was
  first worked and seven more, with the rungs that are still modelled rather than imported stated plainly.

SCOPED INDEXES, each covers one part of this site and is regenerated on every release:
/llms.txt (this file, everything) · /docs/llms.txt · /docs/guidance/llms.txt ·
/docs/vault/llms.txt · /api/llms.txt · /demos/vaults/llms.txt

Notes for agents:
- Agent Contact: this site's contact file is /.well-known/sgit-agents.json (schema sgit-agents/v1);
  the protocol is /docs/agent-contact.md and the network directory is /agents/index.md.
- sgit is in beta and powers production workflows. Honest edges: /docs/limitations.md
- Install: pip install sgit-ai (Python >= 3.11; entry points `sgit` and `sgit-ai`)
- Network requirements: sgit needs HTTPS to your API server (default `dev.send.sgraph.ai`);
  agents reading these docs also need `sgit.ai`. Sandboxed agents (e.g. Claude on Team or
  Enterprise plans) often run behind an allowlisting proxy: `CONNECT ... 403` means the host is
  blocked, not down. Ask the organisation Owner to allowlist `sgit.ai`, `*.sgit.ai` and
  `*.sgraph.ai`, adding the apex domains explicitly. /docs/how-to/claude-team-egress.md
- Vault keys are full-strength generated keys (`passphrase:vault_id`). No password reset exists.
- Never write a vault key into a tracked file. See /case-studies/exposed-vault-key.md for
  what that costs, and /docs/guidance/index.md for the practice that prevents it.
- The agent-facing command is `sgit write <path> --file <f> --message <m> --push --json`;
  every read path has a --json flag.
- Cross-session state pattern: clone/pull at session start, commit + push at session end.
- Task-shaped recipes by industry live under /use-cases/; the guidance for working ON a
  vault moved to /docs/guidance/ and the build briefs to /docs/briefs/.

If your tooling cannot follow links out of this file, fetch /llms-full.txt, every page of
this site concatenated into one document ({LLMS_FULL_SIZE}). One request gets the complete set.

Quick answers (so you do not need a second request for the common questions):
- Does sgit do the git operations you know? Yes for: init, create, clone, status, commit,
  push, pull, fetch, branch (new/list/switch/checkout), merge with conflict resolution,
  history log/diff/show/revert/reset, stash. No for: pull requests (a hosting-platform
  construct layered on merge, not a git primitive), a staging area or partial commits
  (a commit snapshots the whole folder), bisect, blame, rebase, cherry-pick, hooks,
  submodules and tags. Full mapping: /docs/sgit-for-git-users.md
- The three deliberate differences from git: no staging area; every clone gets its own
  private branch and publishing to a shared branch is explicit; the vault key is address,
  credential and encryption key in one string.
- What the server can see: the vault ID, the size of each encrypted object, and request
  timing. Never filenames, contents, or commit messages. /security/index.md
- Read access is a separate, one-way-derived key: publishable, cannot be turned into write
  access, works against any server holding the ciphertext. This site publishes one.
- Crypto: AES-256-GCM, PBKDF2-HMAC-SHA256 at 600k iterations, HKDF-SHA256. No custom
  primitives; output matches the browser Web Crypto API byte for byte.
- Can two vaults SEND MESSAGES to each other? Yes. The transport is an APPEND LANE: a
  write-only channel on the recipient's vault, gated by a hex `append_token` the sender holds.
  The sender POSTs to /api/vault/append/write/{vault_id} with NO account and NO access token;
  the response is blind (`{"ok":true}`, no id, no count). The recipient lists and fetches with
  `x-sgraph-vault-enum-key` and decrypts client-side. Encrypt with `sgit pki encrypt --recipient`
  (RSA-OAEP 4096 + AES-256-GCM). Full worked example: /docs/vault-messaging.md
  The append routes are on dev.send.sgraph.ai only. Two-way, signed use between agents, with
  per-session keys and ephemeral inboxes: /docs/append-lane-messaging.md
- The intended lane address is `append_token = H(recipient public key)`, so a sender can derive
  it from a published key. That derivation is PROPOSED, no shipped command emits it, so today
  you agree the token out of band. The server side is shipped. Do not code against the derivation.
- PKI exists. `sgit pki keygen/list/export/import/contacts/sign/verify/encrypt/decrypt`. The vault
  key is symmetric and roots the storage hierarchy; keypairs layer on top for identity and
  recipient-addressed encryption. /docs/pki.md · /security/index.md#pki
- The HTTP API is the whole surface, the CLI and the browser bridge are both clients. /api/index.md
- sgit is beta; it has no compliance certification of any kind.
"""

LLMS_SECTIONS = [
    ('why', 'Why this exists'),
    ('about', 'About the author (Dinis Cruz: the record, the signed articles, writing elsewhere, and interests declared)'),
    ('partnerships', 'Partnerships (the business case for specific partnerships, made from public material only: what the organisation is trying to do, what is published here that answers it, and a first concrete piece of work)'),
    ('startups', 'For startups (building a product on vaults: what you get on day one, the loop from first vault to first customer, and what you still have to bring)'),
    ('demos', 'Demos (live end-to-end examples with published read keys)'),
    ('catalogue', 'Catalogue (the index of published vaults, read keys, shapes, evidence and write-key status)'),
    ('vaults', 'Published vaults (one page per vault: description, features, live embed, and the read key that opens it)'),
    ('compare', 'Comparisons run as reproducible tests (the entry format, the privilege vocabulary, and the rows where vaults lose)'),
    ('use-cases', 'Use cases (task-shaped guidance: recipe, evidence status, agent brief)'),
    ('case-studies', 'Case studies (worked accounts of what actually happened, with numbers)'),
    ('lessons', 'Lessons learned (the rules this site enforces, each with the incident that produced it)'),
    ('docs', 'Docs'),
    ('agents', 'Agents (the Agent Contact directory: which sites publish a contact file at /.well-known/sgit-agents.json, and how to write to one)'),
    ('api', 'HTTP API (the protocol surface: endpoints, auth headers, capability gates, limits)'),
    ('vault', 'SG/Vault platform'),
    ('deploy', 'Deploy (rendered live from an encrypted vault)'),
    ('try', 'Try it'),
    ('skills', 'Skills (packaged instructions for AI agents)'),
    ('briefs', 'Cross-team briefs'),
    ('team', 'The team (how the site is run by one human and a team of agents, start here if you are an agent)'),
    ('roles', 'Roles (one page per agentic role: mission, what it owns, the rules it enforces, its starting prompt)'),
    ('prompts', 'Starting prompts (the regular tasks, each as a prompt to paste into a fresh agent)'),
    ('board', 'The board (open work as a kanban of files; Needs only the author can supply, Tasks an agent can pick up)'),
    ('investors', 'Investors (the pitch in the open: architecture, computed traction, business model, and the ask left open until stated)'),
    ('updates', 'Updates (dated posts: what changed, one entry per story)'),
    ('articles', 'Articles (longer pieces that argue across pages, with the evidence linked)'),
    ('collections', 'Collections (articles read together, each set with an introduction saying what it shows that no single article does)'),
    ('account', 'Your reading account (a reading meter kept in the browser: prices charged by scroll depth, balance that may go negative, history, top-ups and the personalisation they buy)'),
    ('share', 'A newsroom designed for you (send your reading, encrypted or copied, and get back a front page designed from it)'),
    ('yours', 'Your newsroom (a front page per reading persona, worked out in the browser: picks, kept articles, the persona graph; and managing personas)'),
    ('meter', 'SG Meter (the reading meter as a library any website can add: install, views, config, events, storage, theming, Stripe, security model)'),
    ('subscribe', 'Subscribe (the one page with the newsletter sign-up form; the address is encrypted in the browser)'),
    ('newsletter', 'The newsletter (the regular SGit Newsroom issue: what was published and what it adds up to, also posted on LinkedIn)'),
    ('desk', 'From the desk (short pieces written from the articles: nuggets, threads across articles, the week in one page)'),
    ('newsroom', 'The newsroom (how articles are written, placed and connected: desk roles, behaviour policies, the board, the run log, and newsroom/wire.json for subscriber agents)'),
    ('network', 'The sgit.ai network (sibling sites on *.sgit.ai subdomains, each pursuing one question)'),
    ('home', 'Optional'),
    ('security', 'Optional'),
    ('admin', 'Optional'),
]

LLMS_FACTS = {
    'docs/vault-messaging.html': 'Two vaults exchange encrypted messages over an APPEND LANE: a write-only channel on the recipient vault. The sender holds an append_token (hex, ^[0-9a-f]{16,128}$) and POSTs to /api/vault/append/write/{vault_id} with no account and no access token; the response is blind ({"ok":true}, no id, no count). The recipient lists/fetches with x-sgraph-vault-enum-key and decrypts locally. The intended lane address is append_token = H(recipient public key), but no shipped command emits it, PROPOSED; today agree the token out of band.',
    'docs/pki.html': 'sgit pki keygen makes TWO pairs: RSA-OAEP 4096 for encryption and ECDSA P-256 for signing (not X25519/Ed25519), passphrase-protected. export emits a JSON bundle {v,encrypt,sign,label,fingerprint,signing_fingerprint} of PEM blocks, not a bare PEM. encrypt --recipient <fingerprint>; decrypt --fingerprint <fingerprint> (required). Envelope v2 is base64 JSON {v,w,i,c}: RSA-OAEP wraps an AES-256-GCM content key. No revocation, no directory.',
    'api/index.html': 'The HTTP API is the whole surface; the CLI and the browser bridge are clients. The server is a capability-checked ciphertext store: it holds SHA-256 of each capability key and compares hashes, never a raw key and never a private key. Four capabilities: append_token (write one lane), enum_key (list/fetch/mark), write_key (configure/purge/write objects), private key (decrypt, client-side only).',
    'api/append-lanes.html': 'Six POST endpoints under /api/vault/append/: configure (write key), write (append_token in body, account-less), list (enum key), fetch, mark-processed (idempotent), purge (folder: pending|processed). Renamed from inbox in v0.32.7, /api/vault/inbox/* is gone. Limits: 5MB payload (413), 1000 pending per token (507), 100 file_ids per batch (400), 3MB inline content (413), page 50/200.',
    'api/errors.html': '400 = malformed input, rejected before any gate; 403 = well-formed but wrong capability. A prefixed token (tok_..., or a CLI fingerprint sha256:...) returns 400, NOT 403, append_token is hex only. 413 = too large, 507 = lane full at 1000 pending.',
    'api/authentication.html': 'Six headers: x-sgraph-access-token, x-sgraph-vault-write-key, x-sgraph-vault-enum-key, x-vault-read-key, x-vault-public, x-sgraph-transfer-delete-auth. append_token is NOT a header. It goes in the body. Vault reads need no auth on the shared host because the bytes are ciphertext under a key the server never had.',
    'api/vault-objects.html': 'Pointer store endpoints plus the caching contract: file ids containing -imm- are content-addressed and immutable (Cache-Control max-age=31536000, immutable); refs and indexes are mutable and no-store. Caching a ref renders a previous commit from valid ciphertext, so nothing errors and the reader silently sees the wrong version.',
    'docs/sgit-for-git-users.html': 'The three differences: no staging area; private clone branch per machine or agent with explicit publishing; the vault key is address + credential + encryption key in one string.',
    'docs/limitations.html': 'Not a secrets manager; no partial commits; no key recovery; the server cannot index or search; beta.',
    'docs/two-branch-model.html': 'Every clone commits to its own private branch; pushing to a shared named branch is a separate, explicit act.',
    'docs/credentials.html': 'TWO capabilities: a vault key (read+write) and a read key (read only, derived one-way, cannot be reversed). FIVE prefixes DECLARE which you hold: sgit_private_vault_ (write, never publish), sgit_private_read_ (read, keep secret), sgit_public_read_ (read, deliberately published, use this for open vaults), plus legacy sgit_vk1_/sgit_rk1_. The prefix is a DECLARATION, not crypto: strip it and the bytes are identical, and every form clones the same vault. Classification is by declaration, never by shape. Revocation is NOT retroactive.',
    'demos/fractal-graphs/performance.html': 'NO LIVE DATABASE. A graph is encrypted files in object storage, read directly; the engine is built per question and thrown away (SQLite in WebAssembly in the tab, or a serverless function that lives one request). The cycle is LETS: Load bytes, Extract the slice plus its ontology, Transform in a disposable engine, Save the answer as NEW immutable files, never an overwrite, which is why every cache in the path is correct forever. MEASURED 21 Sep 2026 against live vaults: full clone of a 42-file 3.2MB vault 65.4s; sparse clone (every path, size and hash, no content) 7.3s and 256KB; one 59KB file 0.49s; the whole 617-node graph as one file 1.0MB in 1.21s; an already-fetched file 0.18s with no network. In-browser bytes per view: overview 94KB in 3 requests, guidance 295KB in 4, the 617-node graph 1.12MB in 5, data and queries 1.19MB in 6, against a 3.2MB vault that is never loaded whole. An ontology is 2-4KB, the entire price of crossing into another world. COST: zero instance hours, zero replicas, zero index memory, no separate backup line (every version is already kept); storage and egress only; query compute is paid by the reader device. The whole published estate is 2,662 files and 295MB. SLOWER AT: full clones, anything needing a server-side query, deep traversal over one huge single-schema graph (use a graph database), concurrent multi-writer. THE TRANSPORT: file ids are DERIVED by HMAC-SHA256 over the read key under named domains (sg-vault-v1:file-id:ref, :branch-ref, :branch-index), so a client COMPUTES an address instead of looking it up, and reading an encrypted file is ONE unauthenticated CORS GET (measured 2,364 B ciphertext in 0.86s, 59,324 B in 0.63s). One POST to /api/vault/batch/{vault_id} reads MANY: 5 objects 0.87s, 20 objects and 3.76MB in 2.79s, one round trip; 42 returned 502 (response-size limit, the CLI splits the chunk). Encryption adds a FLAT 28 bytes per object (12-byte nonce + 16-byte tag), not a percentage. Two addressing modes: pin the content-addressed obj-cas-imm id for one GET forever, or resolve HEAD to tree to blob for always-current. The 65s clone is a CLIENT-side cost (106 blobs including history, plus one 50-file batch degrading to 50 single fetches), not a transport limit. APPEND MODE: lanes live at bare/append/{token}/pending/ OUTSIDE the commit tree, so a write is one account-less POST with no read-modify-write, no commit, no tree rebuild and no lock, it never conflicts with a push and never contends with a read, and the response is deliberately blind. CACHING: the cached bytes are CIPHERTEXT so a cache is not a trust boundary, and an obj-cas-imm id is SHA-256 over the ciphertext so an entry can never be stale; refs are the exception and must not be cached. Live example: sgraph.ai/en-gb/library/ serves a whole knowledge base from two encrypted vaults behind a 270KB static shell. THE DESIGN IS THE BIGGEST FACTOR: start by asking WHAT DATA THIS QUESTION NEEDS, and compose three layers that are already fast (the file system with its page cache, a content-addressed hash store where a name is a hash, and the graph that tells you which bytes to ask for). A database is fast mainly because it keeps the working set in memory; same insight, but a buffer pool guesses the set in advance for all readers while we pick it per question and discard it. MEASURED on the 1.0MB graph file: cold network fetch 1,210ms, local disk read 2.5ms (418 MB/s), AES-256-GCM DECRYPT 0.351ms (2,978 MB/s), JSON parse 3.2ms. Decryption is 0.03% of the fetch and a TENTH of the JSON parse, so encryption is effectively free and the network is everything. BUDGET RULE: <100KB is an answer, ~1MB is a whole world, 10MB+ means you are loading a store not an answer, GB is a design error. SAVE COMPOUNDS: each pass leaves an artefact cut for the next question (the Article 9 slice makes re-asking cost 29KB not 1MB). SEMANTIC COMPRESSION LADDER, Regulation Graph: raw source 11,216,043 B, graph 1,073,915 B (10x), one article slice 29,638 B (36x), ontology 4,217 B (7x) = 2,660x top to bottom with NOTHING discarded, because each level keeps the edge to the one below. Therefore the store can be TB while a query stays in MB: adding data adds nodes you did not load. NOTE: the DSIT graph measured here has 1,051 nodes and 1,289 edges (its vault page says 617/694, the figure at publication before release 0.2.1). LATENCY IS A FIXED PER-REQUEST COST, NOT A BYTE COST: a GET returning 340 bytes took 0.331s and one returning 41KB took 0.309s, the same; at 1.0MB the total is 0.647s of which only 0.300s is transfer. That ~0.33s floor is the PER-INVOCATION COST OF THE SERVERLESS DEPLOYMENT, not a property of reading encrypted data, and SG/API also runs on EC2 with a warm process where it does not apply (not measured here). BATCHING AMORTISES IT: 1 object 0.590s, 2 in one batch 0.394s, 10 in 0.872s, 18 in 1.528s, fitting TIME = 0.25s FIXED + ~71ms PER OBJECT + transfer. 18 objects one at a time is ~6.3s versus 1.53s batched, 4x. The batch endpoint takes up to 100 operations, MIXED READS AND WRITES (read, write, write-if-match, delete) authorised per operation; write-if-match carries the SHA-256 of expected content and IF ANY MATCH FAILS THE WHOLE BATCH IS REJECTED, giving optimistic concurrency over many files in one round trip with no lock. Large blobs use presigned URLs. Known next optimisations: fetch batch objects concurrently server-side (the 71ms/object is near-linear, so it looks serial), and chunk by accumulated size rather than file count to stop the 502. THE API IS A CONVENIENCE OVER THE STORE, NOT A REQUIREMENT: readers can go straight to the bucket or a CDN with NO function in the path, and this is SAFE because every object is ciphertext and every name is a hash, so public GETs disclose only object size and request timing (the side channel the security model already names). MEASURED, same client, same vault, same 106 objects, only the path changed: full clone through the serverless API 65.4s, full clone over PLAIN GETS 2.63s, off a local folder 2.05s = 25x (the static host was localhost so the network was free; a real CDN adds edge latency). Already shipping as `sgit clone --transport static <any GET host>`, which sniffs one of two published layouts, fans out 8 parallel GETs, and records every URL touched so a test can assert no key material was ever sent. THE DIRECT PATH IS ALREADY IN PRODUCTION FOR LARGE FILES: anything over 4MB (safe margin under the ~4.7MB serverless base64 limit) is handed a presigned URL and fetched straight from storage, which is also the 502 fallback. Further ordinary options: CDN in front of the bucket, ranged/parallel GETs for 100-500MB objects, lower-latency classes such as S3 Express One Zone (not benchmarked here), or another provider entirely. FINDING AN OBJECT: usually the id is already pinned in the page; otherwise ref -> commit -> tree -> blob, a couple of requests, not a clone. Measured: one named file out of a sparse clone = 2 requests (one of them a skippable layout re-probe). Git cannot do this because its objects are packed and its transport negotiated. GAP: there is NO history-depth flag. --sparse skips content, --bare skips the working copy, but a full clone still takes 106 blobs where the current tree is 42 files; depth control would most improve clone time.',
    'security.html': 'The server sees the vault ID, object sizes and request timing, nothing else. Sizes and timing are an acknowledged side channel.',
    'docs/agents.html': 'sgit write <path> --file <f> --message <m> --push --json is the one-shot agent command; every read path takes --json.',
    'docs/quickstart.html': 'sgit create <name> then commit/push; the vault key is printed once and there is no reset.',
    'docs/installation.html': 'pip install sgit-ai, Python >= 3.11, two runtime dependencies, entry points sgit and sgit-ai.',
    'use-cases/health-regulated.html': 'No HIPAA, ISO 27001, SOC 2 or GDPR finding exists; client-side encryption does not by itself make processing lawful.',
    'deploy/how-this-works.html': 'The ref is checked at most once per 120s freshness window; every other object is content-addressed and cached forever.',
}

LLMS_EXTRA = {
    'skills': ['- [use sgit and vaults](/skills/use_sgit-and-vaults__SKILL.md): the CLI + cross-session persistent state',
               '- [create vault apps](/skills/create-vault-apps__SKILL.md): build an app that lives inside a vault',
               '- [create vault content](/skills/create-vault-content__SKILL.md): author _page.json layouts and vault markdown'],
}


def write_site_index(pages):
    """assets/site-index.json, what the chat pane's tools search. One derived file over the
    same data as llms.txt, the vaults table, the network directory, the updates feed and the
    board, so an answer given in the pane is an answer the site already gives somewhere."""
    ix = {
        'site': 'https://sgit.ai', 'version': SITE_VERSION,
        'pages': [{'path': p, 'title': t, 'desc': d, 'section': sec} for p, t, d, sec, _ in pages],
        'vaults': [{'n': v['n'], 'name': v['name'], 'vault_id': v['vault_id'], 'category': v['category'],
                    'what': v['what'], 'files': v['files'], 'size': v['size'], 'published': v['published'],
                    'path': f'demos/vaults/{v["slug"]}/index.html'} for v in _vaults()],
        'sites': [{'domain': x['domain'], 'category': x.get('category', ''), 'thesis': x.get('thesis', ''),
                   'tagline': x.get('tagline', ''), 'aliases': x.get('aliases', ''), 'url': x['url'],
                   'live': bool(_site_live(x)), 'path': (None if x['listing'] else f'network/{x["slug"]}.html')} for x in SITES],
        'updates': [{'title': u['title'], 'date': u['date'], 'version': u.get('version', ''),
                     'path': f'updates/index.html#{u["slug"]}'} for u in UPDATES[:30]],
        'articles': [{'title': a['title'], 'date': a['date'], 'summary': a['summary'],
                      'author': a['author'], 'author_url': a['author_url'],
                      'path': f'articles/{a["slug"]}.html'} for a in ARTICLES],
        'roles': [{'title': r['title'], 'mission': r['mission'], 'path': f'team/roles/{r["slug"]}.html'} for r in ROLES],
        'issues': [{'id': i['id'], 'title': i['title'], 'status': i['status'], 'role': i['role'],
                    'priority': i['priority'], 'path': f'team/board.html#{i["id"]}'} for i in ISSUES],
    }
    text = json.dumps(ix, ensure_ascii=False, separators=(',', ':'))
    with open(os.path.join(ROOT, 'assets', 'site-index.json'), 'w') as f:
        f.write(text)
    print(f'wrote assets/site-index.json ({len(text)} bytes, {len(ix["pages"])} pages, {len(ix["vaults"])} vaults, {len(ix["sites"])} sites)')


SCOPED_LLMS = [
    ('docs/vault', 'The vault format and how to serve it',
     'Everything on this site about what a vault IS and how to host, read and embed one, '
     'the storage layout, static hosting with no backend, and reading a single file out of a '
     'vault from a web page.'),
    ('api', 'The HTTP API',
     'The protocol surface behind sgit: vault objects, append lanes, authentication and errors.'),
    ('docs', 'Using sgit',
     'The CLI and the model behind it, installation, quickstart, the two-branch model, '
     'messaging between vaults, and what sgit does not do.'),
]



# Folders that end up holding an llms.txt. Kept beside write_scoped_llms so the two cannot
# drift; the build asserts at the end that every folder named here actually got a file.
def llms_dirs():
    return [''] + ['demos/vaults', 'docs/guidance'] + [f for f, _h, _b in SCOPED_LLMS]


def llms_chip(path):
    """The link an agent wants, on the page a human is reading.

    Shown on every page that sits inside a folder with an llms.txt, deepest wins, so a page
    under /docs/vault/ points at that section's file rather than at /docs/llms.txt. It carries
    the version and date the file was generated, which is the release that produced it: if this
    page changed and that stamp did not, the index has not caught up with the page.
    """
    page_dir = os.path.dirname(path)
    best = None
    for d in llms_dirs():
        if d == '' or path.startswith(d + '/'):
            if best is None or len(d) > len(best):
                best = d
    if best is None:
        return ''
    target = (best + '/llms.txt') if best else 'llms.txt'
    href = os.path.relpath(target, page_dir) if page_dir else target
    ver, date = VERSION_LOG[0][0], VERSION_LOG[0][1]
    try:
        pretty = datetime.date.fromisoformat(date).strftime('%-d %b %Y')
    except Exception:
        pretty = date
    ver, date = VERSION_LOG[0][0], VERSION_LOG[0][1]
    try:
        pretty = datetime.date.fromisoformat(date).strftime('%-d %b %Y')
    except Exception:
        pretty = date
    icon = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
            'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
            '<path d="m8 9-3 3 3 3"/><path d="m16 9 3 3-3 3"/></svg>')
    # The long form lives in the tooltip, not on the page: this chip renders on every page,
    # and as always-on text it was instruction noise sitting above the headline.
    tip = ('Regenerated on every release. If this page changed and that stamp did not, '
           'the index has not caught up.')
    return (f'<div class="llmschip"><a href="{href}" title="{tip}">'
            f'<span class="llmschip-k">{icon}for agents</span>'
            f'<span class="llmschip-p">/{target}</span>'
            f'<span class="llmschip-m">{ver} &middot; {pretty}</span>'
            f'</a></div>')


def write_llms(pages):
    out, seen, optional = [LLMS_PREAMBLE], set(), []
    for key, heading in LLMS_SECTIONS:
        rows = []
        for path, title, desc, here, _ in pages:
            if here != key or path in seen:
                continue
            seen.add(path)
            name = title.split(', ')[0].split(' | ')[0]
            fact = LLMS_FACTS.get(path)
            rows.append(f'- [{name}](/{path[:-5]}.md): {desc}'
                        + (f' **Key fact:** {fact}' if fact else ''))
        rows += LLMS_EXTRA.get(key, [])
        if not rows:
            continue
        if heading == 'Optional':
            optional += rows
        else:
            out.append(f'## {heading}\n' + '\n'.join(rows) + '\n')
    missing = [p for p, *_ in pages if p not in seen]
    if missing:
        raise SystemExit('llms.txt would omit these pages (add their section to LLMS_SECTIONS): '
                         + ', '.join(missing))
    if optional:
        out.append('## Optional\n' + '\n'.join(optional) + '\n')
    text = '\n'.join(out)
    # The size of llms-full.txt is measured, not remembered: a stale figure here was a
    # false memory for every agent that read it (it said ~155 KB while the file was 2.5 MB).
    full = os.path.join(ROOT, 'llms-full.txt')
    size = os.path.getsize(full) if os.path.exists(full) else 0
    text = text.replace('{LLMS_FULL_SIZE}', f'about {size / 1048576:.1f} MB' if size >= 1048576 else f'about {size // 1024} KB')
    with open(os.path.join(ROOT, 'llms.txt'), 'w') as f:
        f.write(text)
    return text




# ============================================================ crawl + agent surface
# An agent reported that it could fetch llms.txt but could not follow a link out of it:
# its harness only allows URLs a search has already returned, and the site did not rank for
# its own positioning language. Two consequences, both handled here: the site has to be
# crawlable (robots + sitemap, and a page that is not invisible without JS, see CRITICAL),
# and the index has to be useful to an agent that will never follow a link, which means
# self-sufficient answers in llms.txt and a single-fetch concatenation in llms-full.txt.

def write_robots():
    text = f"""# sgit.ai, everything here is public and intended to be indexed.
User-agent: *
Allow: /
Disallow: /drafts/

Sitemap: https://sgit.ai/sitemap.xml

# For agents and LLMs:
# https://sgit.ai/llms.txt annotated map of the site, one line per page
# https://sgit.ai/llms-full.txt every page concatenated, one fetch gets everything
# Every page is also available as markdown at the same path with .html swapped for .md.
"""
    with open(os.path.join(ROOT, 'robots.txt'), 'w') as f:
        f.write(text)
    return text


def write_sitemap(pages, today):
    urls = []
    for path, *_ in pages:
        pri = '1.0' if path == 'index.html' else ('0.8' if '/' not in path else '0.6')
        urls.append(f' <url>\n <loc>https://sgit.ai/{path}</loc>\n'
                    f' <lastmod>{today}</lastmod>\n <priority>{pri}</priority>\n </url>')
    for extra in ('llms.txt', 'llms-full.txt'):
        urls.append(f' <url>\n <loc>https://sgit.ai/{extra}</loc>\n'
                    f' <lastmod>{today}</lastmod>\n <priority>0.5</priority>\n </url>')
    text = ('<?xml version="1.0" encoding="UTF-8"?>\n'
            '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
            + '\n'.join(urls) + '\n</urlset>\n')
    with open(os.path.join(ROOT, 'sitemap.xml'), 'w') as f:
        f.write(text)
    return text



def write_scoped_llms(pages):
    """Section-scoped llms.txt files, so an agent pointed at one part of the site gets that
    part's index rather than the whole map.

    /demos/vaults/llms.txt is the one that matters: it is generated from vaults.json, which is
    what the vaults table itself is generated from, so the catalogue an agent reads and the
    table a human reads cannot disagree. Read keys appear because they are published on the
    pages already and are the whole credential for reading; vault keys never appear anywhere.
    """
    written = []

    # ---- the published vaults, from the same data the table is built from
    vs = _vaults() # newest first, the same order the table ships
    # The read key is not in vaults.json. It is printed on each vault's own page, which is the
    # one copy. Lift it from there rather than keeping a second list that could disagree.
    for v in vs:
        try:
            src = open(os.path.join(ADMIN, 'content', 'demos', 'vaults', v['slug'], 'index.html')).read()
            # Every read-key prefix the site may carry, longest-lived first. Missing one does not
            # fail loudly: the optional group simply matches from the hex, and the key is published
            # here stripped of the declaration it was given. sgit_public_read_ was missing for
            # exactly one release after the relabel in v0.2.98.
            m = re.search(r'((?:sgit_(?:rk1_|public_read_|private_read_))?[0-9a-f]{64}:'
                          + re.escape(v['vault_id']) + r')', src)
            if m:
                v['read_key'] = m.group(1)
        except OSError:
            pass
    rows = []
    for v in vs:
        bits = [f"id `{v['vault_id']}`", v.get('category', '')]
        if v.get('published'): bits.append(f"published {v['published']}")
        if v.get('size'): bits.append(v['size'])
        if v.get('files'): bits.append(f"{v['files']} files")
        line = (f"- **#{v.get('n')} {v['name']}**: {v.get('what', '')}\n"
                f" - page: /demos/vaults/{v['slug']}/index.md\n"
                f" - {' · '.join(b for b in bits if b)}")
        if v.get('read_key'):
            line += f"\n - read key: `{v['read_key']}`"
        rows.append(line)
    text = (f"""# Published vaults on sgit.ai

> The catalogue of encrypted vaults published at https://sgit.ai/demos/vaults/index.html,
> generated at build time ({SITE_VERSION}) from the same file the table on that page is built
> from, so this list and that table cannot drift apart.
>
> Every vault below is opened by a READ KEY: a 64-character hex string and a vault id, derived
> one-way from a vault key that is never published. A read key is the complete credential:
> no account, no token, nothing to install. `sgit clone <read key>` from the CLI, or open it at
> https://dev.vault.sgraph.ai/#<read key with the colon percent-encoded>.
>
> The server stores ciphertext under opaque ids and cannot read any of this. How a browser
> reads one file out of a vault is written up at /vault/reading-a-vault-file.md.

## The vaults ({len(vs)})
""" + '\n'.join(rows) + """

## How these were published
- [Publishing a vault: the method](/demos/vaults/publishing.md), the seven steps behind every row above.
- [The vault catalogue](/catalogue/index.md), the same index, rendered live from a vault that indexes vaults.
- [Reading one file out of a vault](/vault/reading-a-vault-file.md), the primitive under every live embed here.
""")
    d = os.path.join(ROOT, 'demos', 'vaults')
    os.makedirs(d, exist_ok=True)
    with open(os.path.join(d, 'llms.txt'), 'w') as f:
        f.write(text)
    written.append(('demos/vaults/llms.txt', len(text), len(vs)))


    # ---- the guidance entry point: the one file to hand an agent before it touches a vault.
    # Hand-shaped rather than a page listing, because its job is to route. It is a node whose
    # whole value is its edges, which is graphs.sgit.ai's argument applied to itself.
    sites = []
    for host, why in [
        ('coding.sgit.ai', 'how code is written here, measured against its own rules, read before writing a vault app'),
        ('nfrs.sgit.ai', 'resilience, budgets, backups: what a vault owes anyone who comes to depend on it'),
        ('graphs.sgit.ai', 'the grammar of semantic graphs, read before modelling anything as data'),
    ]:
        slug = host.split('.')[0]
        meta = next((x for x in SITES if x.get('slug') == slug), {})
        thesis = (meta.get('thesis') or '').rstrip('.')
        sites.append(f'- **https://{host}/**: {why}.' + (f' *"{thesis}"*' if thesis else ''))

    g = f"""# Working on a vault, start here

> The entry point for any agent about to build, publish or change a vault ({SITE_VERSION}).
> Short on purpose: this is a router, not a manual. Every line below is an edge to the page
> that actually answers the question. Human-readable twin: /docs/guidance/index.md

## The five rules that get repeated most
1. **Pick the surface first.** _page.json inside a vault, an HTML vault app inside a vault, or
   a page on a *.sgit.ai site. It changes every other answer, including who distrusts whom.
   /docs/surfaces.md
2. **Do not build what the platform already has.** Markdown rendering, file trees and page
   layouts are free. Most tasks end here. /docs/briefs/markdown-and-file-viewers.md
3. **Publish a read key. Never a vault key.** A read key is derived one-way and cannot become
   write access; a vault key IS write access. Escrow the write key BEFORE publishing.
   /demos/vaults/publishing.md
4. **Version everything, show the version, link what changed.** The number belongs in the app
   chrome and must link to that version's own details, not a generic changelog. Keep
   versions/index.json plus one file per version, each naming the commit it was built from.
   /docs/guidance/index.md#versions
5. **Anything rendered stays one click from the bytes it was rendered from.** A reader that
   only shows its own interpretation is asking to be trusted.

## Read in this order
- [Three surfaces](/docs/surfaces.md), pick where the code runs before anything else.
- [What not to build](/docs/briefs/markdown-and-file-viewers.md), the ladder, rung 0 upward.
- [Content authoring](/docs/vault/content-authoring.md), _page.json and vault markdown syntax.
- [Vault apps](/docs/vault/vault-apps.md) and [the window.sg bridge](/docs/vault/sg-bridge.md), only when a view must compute.
- [Publishing: the method](/demos/vaults/publishing.md), before anything becomes public.

## Build briefs, written to be executed
- [Markdown and file viewers: what not to build](/docs/briefs/markdown-and-file-viewers.md)
- [Decks from a vault, on a site](/docs/briefs/vault-decks-on-a-site.md)
- [Reading a vault from a *.sgit.ai site page](/docs/briefs/sgit-ai-site-pages.md)
- [Telemetry from a published vault](/docs/briefs/vault-telemetry-append-lanes.md)
- [Reading one file out of a vault](/docs/vault/reading-a-vault-file.md), the primitive under the last three.

## Reference implementations, go and look rather than take our word
- [The published vaults](/demos/vaults/index.md), and [the same catalogue with every read key](/demos/vaults/llms.txt).
- Version convention, done properly: the AIUC-1 conformance vault (2wzct4k7), a Versions tab,
  the number in the top bar, one JSON file per version. /demos/vaults/aiuc-1-conformance/index.md
- A file explorer with raw always available, in the same vault: "raw is the point, a catalog
  that asks to be trusted has to be readable in the form it was written."
- Decks read live out of a vault onto a site: /demos/vaults/aiuc-1-conformance/decks/index.md

## Not a vault? Every repository, one page
What every repository in the estate carries regardless of kind, a site, a tool, an app: the
brief in full (written by a person or an agent, accepted by a person), the reality and
corrections files, one version going up on every push, the gate, the release discipline, the
review/ folder with its self set, the code rules by reference to coding.sgit.ai, credentials,
and two checklists. /docs/guidance/repositories.md

## Follow the edge. The answer is often on another domain
This site covers vaults and sgit, and deliberately stops there. An agent that stops at this
domain will give a worse answer than one that follows the edge.
{chr(10).join(sites)}
The full map of sites, with the words each answers to: /network/index.md

## Why this file is mostly links
The estate is one semantic graph served from many domains. A vault is a node; so is a site, a
page, a version, a published read key. What makes any of them useful is not the node but what
it is connected to. So: every page is reachable and has a .md twin, indexes are generated from
the data they index rather than maintained by hand, and each site says one thing properly and
links out instead of summarising the rest badly.
"""
    d = os.path.join(ROOT, 'docs', 'guidance')
    os.makedirs(d, exist_ok=True)
    with open(os.path.join(d, 'llms.txt'), 'w') as f:
        f.write(g)
    written.append(('docs/guidance/llms.txt', len(g), 5))

    # ---- one per section directory that owns a real folder on disk
    for folder, heading, blurb in SCOPED_LLMS:
        rows = [f'- [{t.split(", ")[0].split(" | ")[0]}](/{pp[:-5]}.md): {de}'
                for pp, t, de, _here, _ in pages if pp.startswith(folder + '/')]
        if not rows:
            continue
        body = (f"# {heading}, sgit.ai\n\n> {blurb} Generated at build time ({SITE_VERSION}).\n"
                f"> The whole-site map is /llms.txt; every page here also exists as `.md`.\n\n"
                f"## Pages ({len(rows)})\n" + '\n'.join(rows) + '\n')
        dd = os.path.join(ROOT, folder)
        os.makedirs(dd, exist_ok=True)
        with open(os.path.join(dd, 'llms.txt'), 'w') as f:
            f.write(body)
        written.append((folder + '/llms.txt', len(body), len(rows)))
    got = {name for name, _n, _c in written} | {'llms.txt'}
    for d in llms_dirs():
        want = (d + '/llms.txt') if d else 'llms.txt'
        assert want in got, f'llms_chip() points at {want}, which no generator writes'
    # The routing block at the top of llms.txt is hand-written prose naming generated
    # files. That is exactly the drift this site warns about, so the build checks it:
    # every path the preamble points an agent at must actually have been written.
    import re as _re
    for _m in _re.finditer(r'(?<![\w.])/([A-Za-z0-9][A-Za-z0-9/_-]*\.(?:md|txt))', LLMS_PREAMBLE):
        _want = _m.group(1)
        _disk = _want if _want.endswith('.txt') else _want[:-3] + '.html'
        assert os.path.exists(os.path.join(ROOT, _disk)), (f'llms.txt routing block points at /{_want}, which the build does not produce')
    for name, n, c in written:
        print(f'wrote {name} ({n} bytes, {c} entries)')
    return written


def write_llms_full(pages):
    """Every page in one document. For agents whose harness will not follow a link out of a
    fetched file, a common restriction, this is the difference between the documentation
    being reachable and being unreachable."""
    parts = [f"""# sgit.ai, complete documentation, single file

> Every page of https://sgit.ai concatenated into one document, generated at build time
> ({SITE_VERSION}). Nothing here needs a second request. Individual pages live at the paths
> shown below, as markdown (`.md`) and as HTML (`.html`); the annotated map is /llms.txt.
>
> sgit is git for encrypted vaults: clone, commit, branch and merge files that are encrypted
> client-side (AES-256-GCM) before they leave your machine. The server stores ciphertext under
> opaque IDs, it never sees filenames, contents, or commit messages.
"""]
    for path, title, desc, here, body in pages:
        parts.append(f'\n\n{"=" * 78}\n# {title}\n\n> {desc}\n>\n> Page: https://sgit.ai/{path}\n\n'
                     + to_markdown(body))
    text = '\n'.join(parts).rstrip() + '\n'
    with open(os.path.join(ROOT, 'llms-full.txt'), 'w') as f:
        f.write(text)
    return text





# ============================================================ derived counts
# Counts that appear in prose are computed here, never typed. The newsroom at
# sgit.newsroom.sgit.ai found three pages still saying "thirty-one" vaults and a plans page
# describing two plans while listing five, all hand-typed numbers left behind by later
# releases. A page writes {VAULTS_WORDS} or {PLANS_LIST} and the build fills it in.

_NUM_WORDS = ('zero one two three four five six seven eight nine ten eleven twelve thirteen '
              'fourteen fifteen sixteen seventeen eighteen nineteen').split()
_TENS_WORDS = 'twenty thirty forty fifty sixty seventy eighty ninety'.split()


def number_words(n):
    if n < 20:
        return _NUM_WORDS[n]
    if n < 100:
        t, u = divmod(n, 10)
        return _TENS_WORDS[t - 2] + ('-' + _NUM_WORDS[u] if u else '')
    return str(n)


def business_plan_names():
    body = open(os.path.join(ADMIN, 'content', 'startups', 'business-plans.html')).read()
    table = body.split('id="plans"', 1)[1].split('</table>', 1)[0]
    return re.findall(r'<a href="\.\./demos/vaults/[^"]+/index\.html"><b>([^<]+)</b></a>', table)


def derived_tokens():
    n_vaults = len(json.load(open(os.path.join(ADMIN, 'content', 'vaults.json'))))
    plans = business_plan_names()
    plan_list = ', '.join(plans[:-1]) + ' and ' + plans[-1] if len(plans) > 1 else ''.join(plans)
    words = number_words(n_vaults)
    return {'{VAULTS_N}': str(n_vaults), '{VAULTS_WORDS}': words, '{VAULTS_WORDS_CAP}': words[:1].upper() + words[1:],
            '{PLANS_N}': str(len(plans)), '{PLANS_WORDS}': number_words(len(plans)),
            '{PLANS_WORDS_CAP}': number_words(len(plans)).capitalize(), '{PLANS_LIST}': plan_list,
            '{AGENT_BYLINE}': agent_byline_html()}


def agent_byline():
    # admin/build/agent_byline.json is kept current by the agent's own session; the model fields come
    # from the session's get_session call. Documents written by the agent carry it (EU AI Act, Article 50).
    return json.load(open(os.path.join(ADMIN, 'build', 'agent_byline.json')))


def agent_byline_text(b=None):
    b = b or agent_byline()
    return (f"Written by {b['agent']} ({b['model_name']}, {b['model_id']}), in the {b['session']} session, "
            f"for {b['for']}. AI-generated text, disclosed as Article 50 of the EU AI Act asks; "
            f"the person with editorial responsibility is Dinis Cruz. Replies: {b['agent']}.")


def agent_byline_html(b=None):
    b = b or agent_byline()
    return (f'<a href="mailto:{b["agent"]}">{b["agent"]}</a> ({b["model_name"]}, <code>{b["model_id"]}</code>), '
            f'in the <a href="{b["agent_page"]}">{b["session"]}</a> session, for {b["for"]}. AI-generated text, '
            f'disclosed as Article 50 of the EU AI Act asks; the person with editorial responsibility is Dinis Cruz. '
            f'Replies to <a href="mailto:{b["agent"]}">{b["agent"]}</a>.')


def apply_tokens(text, tokens):
    for k, v in tokens.items():
        text = text.replace(k, v)
    return text

# ============================================================ page registry
# Content lives in admin/content/<path>.html, one file per page, holding only the <main>
# body. This file is the engine: template, markdown mirror, llms/robots/sitemap, and the
# registry below. Adding a page is a new content file plus one row here; nothing in this
# file grows with the site.

MANIFEST = os.path.join(ADMIN, 'content', 'pages.json')


def load_pages():
    with open(MANIFEST) as f:
        rows = json.load(f)
    tokens = derived_tokens()
    pages = []
    for r in rows:
        if r.get('dynamic') == 'versions':
            body = versions_body()
        elif r.get('dynamic') == 'updates':
            body = updates_body()
        elif r.get('dynamic') == 'articles':
            body = articles_index_body()
        elif r.get('dynamic') == 'network':
            body = network_index_body()
        else:
            with open(os.path.join(ADMIN, 'content', r['path'])) as f:
                body = f.read().rstrip('\n')
            # the homepage articles band is derived, not hand-listed
            if '<!--ARTICLES-->' in body:
                body = body.replace('<!--ARTICLES-->', home_articles_band())
            # ditto the vaults table, 25 rows of hand-written <tr> could not be sorted,
            # counted or kept consistent, so it comes from admin/content/vaults.json
            body = body.replace('{VERSION}', SITE_VERSION)
            if '<!--VAULTS-->' in body:
                body = body.replace('<!--VAULTS-->', vaults_table())
            # the homepage's proof bands, all derived from vaults.json + the site's own counts
            for marker, fn in (('<!--HERO-VAULTS-->', home_hero_vaults),
                               ('<!--JOBS-->', home_jobs_band),
                               ('<!--TEAM-->', home_team_band),
                               ('<!--ROLES-->', team_roles_cards),
                               ('<!--BOARD-->', team_board),
                               ('<!--TRACTION-->', investors_traction)):
                if marker in body:
                    body = body.replace(marker, fn())
        pages.append((r['path'], r['title'], apply_tokens(r['desc'], tokens), r['section'], apply_tokens(body, tokens)))
    # One page per article, derived, an article is published by adding its markdown
    # file and nothing else, so it must not need a manifest row either.
    for a in ARTICLES:
        pages.append((f'articles/{a["slug"]}.html',
                      f'{a["title"]}, sgit.ai', a['summary'], 'articles', article_body(a)))
        pages.append((f'articles/versions/{a["slug"]}.html',
                      f'Versions of {a["title"].split(":")[0]}, sgit.ai',
                      f'Every published version of the article "{a["title"].split(":")[0]}", with the date, the words and what changed.',
                      'articles', article_versions_body(a)))
        for _i in range(1, len(VERSIONS[a['slug']])):
            _v = VERSIONS[a['slug']][_i]
            pages.append((f'articles/versions/{a["slug"]}/{_vfile(_v)}',
                          f'{a["title"].split(":")[0]}: what changed in {_v["version"]}, sgit.ai',
                          f'The changes to the article "{a["title"].split(":")[0]}" in {_v["version"]}, paragraph by paragraph.',
                          'articles', article_version_diff_body(a, _i)))
    pages.append(('articles/graphs.html', 'The articles as graphs, sgit.ai',
                  'Every article on sgit.ai as a semantic graph: the ideas it rests on, the claims it '
                  'makes, and how they connect, plus a map of how the articles link to each other.',
                  'articles', articles_graphs_body()))
    # The newsroom: the backstage pages, the collections and the desk notes, all derived
    # from admin/content/newsroom/ (see the newsroom section below).
    pages.extend(newsroom_pages())
    # One page per agentic role, derived, a role is added by writing its file.
    for r in ROLES:
        pages.append((f'team/roles/{r["slug"]}.html',
                      f'{r["title"]}, an agentic role on sgit.ai', r['mission'], 'roles', role_body(r)))
    for x in SITES:
        if x['listing']:
            continue
        pages.append((f'network/{x["slug"]}.html',
                      f'{x["domain"]}, {x["tagline"]}, sgit.ai', x['summary'],
                      'network', site_body(x)))
    return pages


# ============================================================ updates & articles
# Markdown content types. The rule, adopted from the VoiceDebrief journalist pipeline:
# PUBLISHING IS ADDING ONE FILE. The index below, the feed, the manifest and every
# permalink are derived, so two agents publishing on the same day touch two different
# files and cannot conflict. That property is what makes an unattended journalist safe.

LOADER = Content_Loader(os.path.join(ADMIN, 'content'))
UPDATES = [u for u in LOADER.load_updates() if u['status'] == 'published']
ARTICLES = [a for a in LOADER.load_articles() if a['status'] == 'published']

# ---- article versions (admin/build/article_versions.py). v1.0.0 is the article as first published;
# a commit that changes its text is the next minor version, one that changes only other front matter
# the next patch; an uncommitted edit is the version this release publishes. Read from git, so nobody
# keeps the numbers by hand and publishing stays adding one file.
import article_versions as AV
_HIST = AV.all_histories(ROOT, [a['slug'] for a in ARTICLES])
VERSIONS = {}
for _a in ARTICLES:
    _src = open(os.path.join(ADMIN, 'content', 'articles', _a['slug'] + '.md')).read()
    VERSIONS[_a['slug']] = AV.with_working_tree(ROOT, _a['slug'], _HIST[_a['slug']], _src, SITE_VERSION,
                                                datetime.date.today().isoformat())
    _a['article_version'] = VERSIONS[_a['slug']][-1]['version']
for _a in ARTICLES:
    if _a['author']:
        if not _a['author_url']:
            raise SystemExit(f"{_a['where']}: author is set but author_url is not; a byline without a link is a name, not provenance")
        ARTICLE_AUTHORS[f"articles/{_a['slug']}.html"] = (_a['author'], _a['author_url'])
    if _a['license']:
        ARTICLE_LICENSES[f"articles/{_a['slug']}.html"] = _a['license']

# ------------------------------------------------------------ article graphs and threads
# Every article has (or will have) a semantic graph beside it: admin/content/articles/graphs/
# <slug>.json, with a one-sentence teaser, one or two topics from a fixed list, the core idea,
# the concepts and how they connect, and the links the article makes. The cards on the index
# and the homepage, the threads block at the foot of every article, and the graphs page are
# all derived from these. Before the graph exists, the card falls back to the first sentence
# of the summary and to a topic guessed from the tags, so an article is never without a card.
GRAPHS = LOADER.load_article_graphs()
TOPICS = LOADER.TOPICS
TOPIC_LABEL = {t[0]: t[1] for t in TOPICS}
TAG_TOPIC = {
    'agents': 'agents-and-policy', 'riskmandate': 'agents-and-policy', 'agent-behaviour-policy': 'agents-and-policy',
    'insider-threat': 'agents-and-policy', 'connectors': 'agents-and-policy', 'security': 'agents-and-policy',
    'access-policies': 'agents-and-policy', 'non-human-identity': 'agents-and-policy', 'risk': 'agents-and-policy',
    'risk-management': 'agents-and-policy', 'risk-acceptance': 'agents-and-policy', 'governance': 'agents-and-policy',
    'news': 'news-and-evidence', 'provenance': 'news-and-evidence', 'micropayments': 'news-and-evidence',
    'advertising': 'news-and-evidence', 'history': 'news-and-evidence', 'tokens': 'news-and-evidence',
    'llms-txt': 'news-and-evidence',
    'startups': 'startups-and-strategy', 'strategy': 'startups-and-strategy', 'open-source': 'startups-and-strategy',
    'investing': 'startups-and-strategy', 'pricing': 'startups-and-strategy', 'early-access': 'startups-and-strategy',
    'go-to-market': 'startups-and-strategy', 'saas': 'startups-and-strategy', 'economics': 'startups-and-strategy',
    'positioning': 'startups-and-strategy',
    'fractal-semantic-graphs': 'graphs-and-knowledge', 'semantic-graphs': 'graphs-and-knowledge',
    'graphs': 'graphs-and-knowledge', 'custom-ui': 'graphs-and-knowledge', 'inbox': 'graphs-and-knowledge',
    'ci': 'site-and-engineering', 'deploy': 'site-and-engineering', 'homepage': 'site-and-engineering',
    'network': 'site-and-engineering', 'chat': 'site-and-engineering', 'verification': 'site-and-engineering',
    'vaults': 'vaults-and-method', 'method': 'vaults-and-method', 'publishing': 'vaults-and-method',
    'intro': 'vaults-and-method', 'zero-knowledge': 'vaults-and-method', 'supply-chain': 'vaults-and-method',
    'digital-twins': 'vaults-and-method',
}
_RE_ART_LINK = re.compile(r'\]\((?:\.\./articles/|/articles/)?([a-z0-9\-]+)\.html')
_SLUGS = {a['slug'] for a in ARTICLES}
BY_SLUG = {a['slug']: a for a in ARTICLES}
# The thread graph between articles, read from the links the markdown actually makes, so a
# new cross-reference appears on both articles' pages by being written and nowhere else.
ARTICLE_OUT = {a['slug']: [s for s in dict.fromkeys(_RE_ART_LINK.findall(a['body']))
                           if s in _SLUGS and s != a['slug']] for a in ARTICLES}
ARTICLE_IN = {s: [o['slug'] for o in ARTICLES if s in ARTICLE_OUT[o['slug']]] for s in _SLUGS}


def _esc(s):
    return str(s).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')


def art_teaser(a):
    g = GRAPHS.get(a['slug'])
    if g:
        return g['teaser']
    first = re.split(r'(?<=[.!?])\s', a['summary'].strip())[0]
    return first if len(first) <= 200 else first[:197].rstrip() + '…'


def art_topics(a):
    g = GRAPHS.get(a['slug'])
    if g:
        return g['topics']
    seen = []
    for t in a['tags']:
        tp = TAG_TOPIC.get(t)
        if tp and tp not in seen:
            seen.append(tp)
    return seen[:2] or ['vaults-and-method']


def art_card_img(a):
    return a['slug'] + '.webp' if os.path.exists(os.path.join(ROOT, 'articles', 'cards', a['slug'] + '.webp')) else 'default.webp'


def _topic_chips(topics):
    return ''.join(f'<span class="tchip">{TOPIC_LABEL[t]}</span>' for t in topics)


def _card_fig(a, pre):
    return (f'<figure class="shot cardshot" data-shot="{art_card_img(a)}" data-dir="{pre}cards/" '
            f'data-alt="{_esc(a["title"])}"></figure>')


def art_card(a, pre=''):
    """One card: picture, date and topic, title, one-sentence teaser."""
    topics = art_topics(a)
    text = _esc(' '.join([a['title'], art_teaser(a), ' '.join(a['tags'])]).lower())
    n_out, n_in = len(ARTICLE_OUT[a['slug']]), len(ARTICLE_IN[a['slug']])
    threads = (f'<span class="acard-threads">{n_out + n_in} thread{"s" if n_out + n_in != 1 else ""}</span>'
               if n_out + n_in else '')
    return (f'<a class="acard" href="{pre}{a["slug"]}.html" data-topics="{" ".join(topics)}" data-text="{text}">'
            f'{_card_fig(a, pre)}'
            f'<span class="acard-meta"><span class="acard-date">{a["date"]}</span>{_topic_chips(topics[:1])}{threads}</span>'
            f'<b>{a["title"]}</b>'
            f'<span class="acard-teaser">{art_teaser(a)}</span>'
            '</a>')


def art_featured(a, pre=''):
    """The newest article, with room to breathe: picture, teaser, and the core idea."""
    g = GRAPHS.get(a['slug'], {})
    topics = art_topics(a)
    text = _esc(' '.join([a['title'], art_teaser(a), ' '.join(a['tags'])]).lower())
    idea = g.get('core_idea') or ''
    more = ''
    if idea and idea.strip() != art_teaser(a).strip():
        more = f'<span class="afeat-idea">{idea}</span>'
    return (f'<a class="afeat acard" href="{pre}{a["slug"]}.html" data-topics="{" ".join(topics)}" data-text="{text}">'
            f'{_card_fig(a, pre)}'
            f'<span class="afeat-text">'
            f'<span class="acard-meta"><span class="acard-kicker">Latest</span><span class="acard-date">{a["date"]}</span>{_topic_chips(topics)}</span>'
            f'<b>{a["title"]}</b>'
            f'<span class="afeat-teaser">{art_teaser(a)}</span>{more}'
            f'<span class="artcard-go">Read it &rarr;</span>'
            '</span></a>')


def articles_filter_bar():
    counts = Counter(t for a in ARTICLES for t in art_topics(a))
    chips = [f'<button type="button" class="achip" data-topic="" aria-pressed="true">All <span>{len(ARTICLES)}</span></button>']
    for tid, label, _ in TOPICS:
        if counts.get(tid):
            chips.append(f'<button type="button" class="achip" data-topic="{tid}" aria-pressed="false">{label} <span>{counts[tid]}</span></button>')
    return (' <div class="afilter" role="toolbar" aria-label="Filter the articles">' + ''.join(chips)
            + ' <input type="search" class="asearch" placeholder="Search titles, teasers and tags" aria-label="Search the articles"></div>')


ARTICLES_FILTER_JS = """<script>
(function () {
  var cards = [].slice.call(document.querySelectorAll('.acard'));
  var chips = [].slice.call(document.querySelectorAll('.achip'));
  var q = document.querySelector('.asearch');
  var empty = document.getElementById('aempty');
  var topic = '';
  if (!cards.length) return;
  function apply() {
    var s = (q && q.value || '').toLowerCase().trim(), n = 0;
    cards.forEach(function (c) {
      var ok = (!topic || (' ' + c.getAttribute('data-topics') + ' ').indexOf(' ' + topic + ' ') >= 0)
            && (!s || (c.getAttribute('data-text') || '').indexOf(s) >= 0);
      c.hidden = !ok; if (ok) n++;
    });
    if (empty) empty.hidden = n > 0;
  }
  chips.forEach(function (b) {
    b.addEventListener('click', function () {
      topic = b.getAttribute('data-topic') || '';
      chips.forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false'); });
      apply();
    });
  });
  if (q) q.addEventListener('input', apply);
}());
</script>"""


def _art_link_li(slug, pre=''):
    a = BY_SLUG[slug]
    return f'<li><a href="{pre}{slug}.html">{a["title"]}</a> <span class="dim">{art_teaser(a)}</span></li>'


def article_threads_block(a):
    """The foot of every article: its topics, the articles it builds on, the ones that
    continue it, and the link to its graph. Derived from the links in the markdown."""
    out_, in_ = ARTICLE_OUT[a['slug']], ARTICLE_IN[a['slug']]
    has_graph = a['slug'] in GRAPHS
    cols = []
    if out_:
        cols.append('<div><h3>Builds on</h3><ul>' + ''.join(_art_link_li(s) for s in out_) + '</ul></div>')
    if in_:
        cols.append('<div><h3>Continued by</h3><ul>' + ''.join(_art_link_li(s) for s in in_) + '</ul></div>')
    graph = (f'<a href="graphs.html#{a["slug"]}">This article as a graph &rarr;</a>' if has_graph
             else '<span class="dim">Graph not written yet.</span>')
    return ('\n<section class="athreads" id="threads">\n'
            f' <h2>Threads</h2>\n'
            f' <p class="athreads-meta">{_topic_chips(art_topics(a))} {graph}</p>\n'
            + (f' <div class="athreads-cols">{"".join(cols)}</div>\n' if cols else
               ' <p class="small dim">No other article links here yet.</p>\n')
            + ' <p class="small dim"><a href="index.html">All articles</a> &middot; <a href="graphs.html">All graphs</a></p>\n'
            '</section>')


# ---- SVG renderings of the graphs. Deterministic layouts (an ellipse for a single article,
# a circle in date order for the map) rather than a force simulation: the same file always
# draws the same picture, which is what a reader comparing two versions needs.
KIND_COLOUR = {'concept': '#0f766e', 'claim': '#b45309', 'method': '#2b5fa8', 'artefact': '#1f7a4d',
               'example': '#a16207', 'question': '#8a8d94'}


def graph_svg(g, w=760, h=440):
    import math
    nodes = g.get('nodes', [])
    if not nodes:
        return ''
    cx, cy, rx, ry = w / 2, h / 2, w / 2 - 215, h / 2 - 44
    pos = {}
    for i, n in enumerate(nodes):
        ang = -math.pi / 2 + 2 * math.pi * i / len(nodes)
        pos[n['id']] = (cx + rx * math.cos(ang), cy + ry * math.sin(ang), ang)
    out = [f'<svg class="agraph-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" role="img" '
           f'aria-label="{_esc(g.get("core_idea", ""))}">']
    for e in g.get('edges', []):
        x1, y1, _ = pos[e['from']]; x2, y2, _ = pos[e['to']]
        out.append(f'<line x1="{x1:.0f}" y1="{y1:.0f}" x2="{x2:.0f}" y2="{y2:.0f}" stroke="#c9c4b6" stroke-width="1.2">'
                   f'<title>{_esc(e["from"])} {_esc(e["rel"])} {_esc(e["to"])}</title></line>')
    for n in nodes:
        x, y, ang = pos[n['id']]
        right = math.cos(ang) >= 0
        tx = x + (12 if right else -12)
        out.append(f'<g><circle cx="{x:.0f}" cy="{y:.0f}" r="6.5" fill="{KIND_COLOUR.get(n["kind"], "#666")}">'
                   f'<title>{_esc(n.get("summary", ""))}</title></circle>'
                   f'<text x="{tx:.0f}" y="{y + 4:.0f}" font-size="12" text-anchor="{"start" if right else "end"}" '
                   f'fill="#1c1d21">{_esc(n["label"] if len(n["label"]) <= 32 else n["label"][:31].rstrip() + "…")}</text></g>')
    out.append('</svg>')
    return ''.join(out)


def articles_map_svg(w=960, h=880):
    """The map of the articles: every article in date order round a circle, joined by the
    links the markdown makes. Labels are set along each node's own radius, so twenty-eight
    of them never overprint and none floats off the ring (v0.6.66 fixed a label that did).
    Node size is how many articles link to it. Edge width is how many times the linking
    article mentions the linked one. Colour is direction in time: an article citing an older
    one is the ordinary case; an older article pointing at a newer one means the older page
    was updated after the newer one existed, which is the record being kept current."""
    import math, collections
    arts = list(reversed(ARTICLES))  # oldest first round the circle
    order = {a['slug']: i for i, a in enumerate(arts)}
    cx, cy, r = w / 2, h / 2, min(w, h) / 2 - 218
    pos = {}
    for i, a in enumerate(arts):
        ang = -math.pi / 2 + 2 * math.pi * i / len(arts)
        pos[a['slug']] = (cx + r * math.cos(ang), cy + r * math.sin(ang), ang)
    weights = {a['slug']: collections.Counter(t for t in _RE_ART_LINK.findall(a['body']) if t in _SLUGS and t != a['slug'])
               for a in ARTICLES}
    out = [f'<svg class="amap-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" role="img" '
           'aria-label="The articles, in date order round a circle, joined by the links they make to each other">']
    for s_, outs in ARTICLE_OUT.items():
        for t in outs:
            x1, y1, _ = pos[s_]; x2, y2, _ = pos[t]
            n = weights[s_][t]
            forward = order[s_] < order[t]  # an older article linking a newer one
            col = '#b45309' if forward else '#0f766e'
            out.append(f'<path d="M {x1:.0f} {y1:.0f} Q {cx:.0f} {cy:.0f} {x2:.0f} {y2:.0f}" fill="none" '
                       f'stroke="{col}" stroke-opacity="{0.22 + min(n, 4) * 0.08:.2f}" stroke-width="{0.9 + 0.5 * min(n, 5):.1f}">'
                       f'<title>{_esc(BY_SLUG[s_]["title"])} links to {_esc(BY_SLUG[t]["title"])}'
                       f'{" (" + str(n) + " times)" if n > 1 else ""}{"; the older article was updated to point forward" if forward else ""}</title></path>')
    for a in arts:
        x, y, ang = pos[a['slug']]
        n_in = len(ARTICLE_IN[a['slug']])
        rad = 4.5 + 1.8 * n_in
        label = _short(a['title'], 30)
        deg = math.degrees(ang)
        right = math.cos(ang) >= 0
        # the label runs along the node's own radius, outward; on the left half it is turned
        # 180 degrees and anchored at its end so it still reads left to right
        lx, ly = x + (rad + 7) * math.cos(ang), y + (rad + 7) * math.sin(ang)
        rot = deg if right else deg + 180
        out.append(f'<a href="{a["slug"]}.html"><circle cx="{x:.0f}" cy="{y:.0f}" r="{rad:.1f}" fill="#0f766e">'
                   f'<title>{_esc(a["title"])} ({a["date"]}): links to {len(ARTICLE_OUT[a["slug"]])}, linked by {n_in}</title></circle>'
                   f'<text x="{lx:.0f}" y="{ly:.0f}" font-size="11" text-anchor="{"start" if right else "end"}" dominant-baseline="middle" '
                   f'transform="rotate({rot:.1f} {lx:.0f} {ly:.0f})" fill="#1c1d21">{_esc(label)}</text></a>')
    out.append('</svg>')
    return ''.join(out)


def articles_map_stats():
    """One line of numbers under the map, computed from the same links the map draws."""
    import collections
    arts = list(reversed(ARTICLES)); order = {a['slug']: i for i, a in enumerate(arts)}
    n_links = sum(len(v) for v in ARTICLE_OUT.values())
    n_mentions = sum(len(_RE_ART_LINK.findall(a['body'])) for a in ARTICLES)
    citing_earlier = sum(1 for a in ARTICLES if any(order[t] < order[a['slug']] for t in ARTICLE_OUT[a['slug']]))
    forward = sum(1 for s_, outs in ARTICLE_OUT.items() for t in outs if order[s_] < order[t])
    most_in = max(ARTICLES, key=lambda a: len(ARTICLE_IN[a['slug']]))
    most_out = max(ARTICLES, key=lambda a: len(ARTICLE_OUT[a['slug']]))
    isolated = [a for a in ARTICLES if not ARTICLE_OUT[a['slug']] and not ARTICLE_IN[a['slug']]]
    return (f'<p class="small dim">{len(ARTICLES)} articles, {n_links} links between them ({n_mentions} mentions in all). '
            f'{citing_earlier} articles cite an earlier one; {forward} links in amber run from an older article to a newer one, '
            f'which means the older page was updated after the newer one existed. Most linked: '
            f'<a href="#{most_in["slug"]}">{_esc(_short(most_in["title"], 48))}</a> ({len(ARTICLE_IN[most_in["slug"]])} articles link to it). '
            f'Most linking: <a href="#{most_out["slug"]}">{_esc(_short(most_out["title"], 48))}</a> ({len(ARTICLE_OUT[most_out["slug"]])} links out). '
            f'{len(isolated)} article{"s" if len(isolated) != 1 else ""} not yet linked either way.</p>')


def articles_graphs_body():
    out = ['<main class="doc">',
           ' <p class="crumb"><a href="../index.html">Home</a> / <a href="index.html">Articles</a> / Graphs</p>',
           ' <h1>The articles as graphs</h1>',
           ' <p class="lead">Every article here has a semantic graph beside it: the ideas it rests on, the claims it makes, '
           'the methods and the examples, and how they connect. The map first, then one graph per article. '
           'Hover a node for its summary and an edge for its relation.</p>',
           ' <p class="small dim">The graphs are data first and pictures second. Take them as JSON: '
           '<a href="graphs.json">all articles in one file</a>, with the topics, the teasers and the links '
           'between articles resolved, or one file per article at <code>articles/graphs/&lt;slug&gt;.json</code> '
           '(for example <a href="graphs/footprint-and-blast-radius.json">this one</a>). The pages on this site are '
           'rendered from the same files at build time; nothing here is hand-written HTML.</p>',
           f' <p class="small dim">{len(GRAPHS)} of {len(ARTICLES)} articles have a graph. The map draws every article in '
           'date order round the circle, oldest at the top and clockwise from there, sized by how many other articles '
           'link to it. Teal edges run from an article to an earlier one it cites; amber edges run the other way, from an '
           'older article that was updated to point at a newer one. Thicker edges are articles that mention each other '
           'more than once.</p>',
           ' <h2 id="map">How the articles connect</h2>',
           ' <div class="amap">' + articles_map_svg() + '</div>',
           ' ' + articles_map_stats()]
    for a in ARTICLES:
        g = GRAPHS.get(a['slug'])
        out.append(f' <section class="agraph" id="{a["slug"]}">')
        out.append(f' <h2><a href="{a["slug"]}.html">{a["title"]}</a></h2>')
        out.append(f' <p class="small dim">{a["date"]} &middot; {_topic_chips(art_topics(a))}</p>')
        if not g:
            out.append(' <p class="dim">Graph not written yet.</p></section>')
            continue
        out.append(f' <p class="agraph-idea">{g.get("core_idea", "")}</p>')
        out.append(' ' + graph_svg(g))
        legend = ''.join(f'<span class="klegend"><i style="background:{KIND_COLOUR[k]}"></i>{k}</span>'
                         for k in ('concept', 'claim', 'method', 'artefact', 'example', 'question')
                         if any(n['kind'] == k for n in g['nodes']))
        out.append(f' <p class="small dim agraph-legend">{legend}</p>')
        out.append(f' <details class="agraph-nodes"><summary>{len(g["nodes"])} nodes, {len(g["edges"])} edges</summary><ul>')
        for n in g['nodes']:
            out.append(f'<li><b>{n["label"]}</b> <span class="dim">({n["kind"]})</span> {n.get("summary", "")}</li>')
        out.append(' </ul></details>')
        for qt in g.get('quotes', [])[:2]:
            out.append(f' <blockquote class="agraph-quote">{qt["text"]}<span class="dim"> {qt.get("why", "")}</span></blockquote>')
        rel = []
        if ARTICLE_OUT[a['slug']]:
            rel.append('builds on ' + ', '.join(f'<a href="#{s}">{BY_SLUG[s]["title"]}</a>' for s in ARTICLE_OUT[a['slug']]))
        if ARTICLE_IN[a['slug']]:
            rel.append('continued by ' + ', '.join(f'<a href="#{s}">{BY_SLUG[s]["title"]}</a>' for s in ARTICLE_IN[a['slug']]))
        if rel:
            out.append(f' <p class="small dim">{"; ".join(rel)}.</p>')
        out.append(' </section>')
    out.append(' <p class="small dim" style="margin-top:2rem"><a href="index.html">&larr; All articles</a></p>')
    out.append('</main>')
    return '\n'.join(out)


# An article with a hero figure must also have its link-preview card, or og_card() below
# quietly falls back to og/default.jpg and the article ships with the generic card. That
# is invisible from the page and from the validator (the tag is present and the file it
# names exists), and it only shows up when somebody shares the link. The cards are built
# by a separate tool because they need sharp, so this is the reminder to run it. Same
# shape as the LLMS_SECTIONS check: the build refuses rather than guessing.
_missing_cards = []
for _a in ARTICLES:
    if not re.search(r'^!shot\s+([^\s|]+)\s*\|\s*([^\s|]+)', _a['body'], re.M):
        continue
    if not os.path.exists(os.path.join(ROOT, 'og', _a['slug'] + '.jpg')):
        _missing_cards.append(_a['slug'])
if _missing_cards:
    raise SystemExit(
        'these articles have a hero figure but no link-preview card, so they would ship\n'
        'with the generic default and nobody would notice until the link was shared:\n  '
        + '\n  '.join(_missing_cards)
        + '\nrun: node admin/build/make_og_cards.mjs')
SITES = [x for x in LOADER.load_sites() if x['status'] == 'published']
ROLES = LOADER.load_roles()
ISSUES = LOADER.load_issues()


def _chips(tags):
    return ''.join(f'<span class="chip">{t}</span>' for t in tags)


def updates_body():
    """One page, newest first, each post with a #slug permalink. Posts are short by
    design, a release with three stories in it gets three of them, which is the whole
    reason this is not just the version log with a nicer stylesheet."""
    if not UPDATES:
        return '<main class="doc"><h1>Updates</h1><p>Nothing published yet.</p></main>'
    out = ['<main class="doc">',
           ' <h1>Updates</h1>',
           ' <p class="lead">What changed on sgit and on this site, as it happens, one entry per '
           'story rather than per release. The <a href="../admin/versions.html">version log</a> is '
           'the complete technical record; this is the readable one.</p>',
           ' <p class="small dim">Follow along: <a href="feed.xml">RSS</a> &middot; '
           '<a href="updates.json">JSON</a>. Every entry links to the release that carries it.</p>']
    # A contents list first. Sixty-odd entries on one page is a long scroll, and a reader
    # looking for one of them should not have to make it.
    out.append(f' <details class="updtoc" open><summary>{len(UPDATES)} entries, newest first</summary><ul>')
    for u in UPDATES:
        out.append(f'<li><span class="dim">{u["date"]}</span> <a href="#{u["slug"]}">{u["title"]}</a></li>')
    out.append(' </ul></details>')
    last_date = None
    for u in UPDATES:
        if u['date'] != last_date:
            out.append(f' <h2 class="upd-date">{u["date"]}</h2>')
            last_date = u['date']
        ver = (f'<a class="upd-ver" href="../admin/versions.html">{u["version"]}</a>'
               if u['version'] else '')
        out.append(f' <article class="upd" id="{u["slug"]}">')
        out.append(f' <h3><a href="#{u["slug"]}">{u["title"]}</a> {ver}</h3>')
        if u['tags']:
            out.append(f' <p class="chips">{_chips(u["tags"])}</p>')
        out.append(LOADER.md_to_html(u['body'], depth=1, where=u['where']))
        out.append(' </article>')
    out.append('</main>')
    return '\n'.join(out)


def vaults_table():
    """The published-vaults table, generated from admin/content/vaults.json.

    It was 25 hand-written <tr> rows carrying the read key and an 'open live' link on
    every one. Three problems with that, all reported from a phone: the read key is a
    64-hex string that forces the vault id column down to one character per line; the
    key and the live link are both already on each vault's own page, one click away, so
    the widest two columns were the two nobody needed here; and a hand-written table
    cannot be sorted, counted, or kept consistent as it grows.

    So the index answers 'which of these do I want' (name, what it is, category, size,
    when it was published) and the vault's page answers 'how do I open it'. Sorting is
    progressive enhancement: the rows ship newest-first in the HTML, so with no
    JavaScript the table is still correct and still in the most useful order.

    That last sentence was a claim rather than a fact until v0.2.99: this read the JSON
    directly, in file order, and four vaults appended to the end of the file rendered at the
    bottom of the table. It now reads _vaults(), which sorts and asserts.

    published: the date the vault's page first appeared in git, not a date anybody typed.

    The # column is a PERMANENT publication ordinal (#1 is the first vault ever published
    here, #31 the newest), not the row's position on screen. It therefore does not change
    when the table is re-sorted, and sorting by # is by construction the same ordering as
    sorting by published date. A number that renumbered on every sort said nothing at all.
    """
    rows = _vaults() # newest first, asserted there
    trs = '\n'.join(f' <tr><td class="vt-n" data-sort="{v["n"]}">{v["n"]}</td>'
        f'<td><a href="{v["slug"]}/index.html">{v["name"]}</a>'
        f'<div class="vt-id"><code>{v["vault_id"]}</code></div></td>'
        f'<td>{v["what"]}</td>'
        f'<td><span class="vt-cat">{v["category"]}</span></td>'
        f'<td class="vt-num" data-sort="{v["files"]}">{v["files"]:}</td>'
        f'<td class="vt-num" data-sort="{v["bytes"]}">{v["size"]}</td>'
        f'<td class="vt-num" data-sort="{v["published"]}">{v["published"]}</td></tr>'
        for v in rows)
    cats = ', '.join(f'{n}&nbsp;{c.lower()}' for c, n in
                     sorted(Counter(v['category'] for v in rows).items(),
                            key=lambda kv: (-kv[1], kv[0])))
    return f"""<p class="vt-count"><b>{len(rows)} published vaults</b>: {cats}.
  Newest first; <b>click any heading to sort</b>. Every read key and the live link are on the vault's own page.</p>
  <div class="tablewrap"><table class="vt" id="vaults">
    <tr><th class="vt-n">#</th><th>Vault</th><th>What it is</th><th>Category</th><th class="vt-num">Files</th><th class="vt-num">Size</th><th class="vt-num">Published</th></tr>
{trs}
  </table></div>
  <script>
  (function () {{
    var t = document.getElementById('vaults'); if (!t) return;
    var ths = t.rows[0].cells, dir = {{}};
    for (var i = 0; i < ths.length; i++) (function (col) {{
      var th = ths[col];
      th.tabIndex = 0; th.setAttribute('role', 'button'); th.classList.add('vt-sortable');
      function sort() {{
        var body = Array.prototype.slice.call(t.rows, 1);
        var d = dir[col] = !dir[col];
        body.sort(function (a, b) {{
          var x = a.cells[col], y = b.cells[col];
          var xv = x.getAttribute('data-sort'), yv = y.getAttribute('data-sort');
          if (xv !== null && yv !== null) {{
            var nx = parseFloat(xv), ny = parseFloat(yv);
            if (!isNaN(nx) && !isNaN(ny)) return d ? nx - ny : ny - nx;
            return d ? (xv > yv ? 1 : xv < yv ? -1 : 0): (yv > xv ? 1 : yv < xv ? -1 : 0);
          }}
          var a1 = x.textContent.trim().toLowerCase(), b1 = y.textContent.trim().toLowerCase();
          return d ? (a1 > b1 ? 1 : a1 < b1 ? -1 : 0): (b1 > a1 ? 1 : b1 < a1 ? -1 : 0);
        }});
        for (var k = 0; k < ths.length; k++) ths[k].removeAttribute('data-dir');
        th.setAttribute('data-dir', d ? 'asc' : 'desc');
        body.forEach(function (r) {{ t.tBodies[0].appendChild(r); }});
      }}
      th.addEventListener('click', sort);
      th.addEventListener('keydown', function (e) {{ if (e.key === 'Enter' || e.key === ' ') {{ e.preventDefault(); sort(); }} }});
    }})(i);
  }}());
  </script>"""


def _vaults():
    """Every published vault, NEWEST FIRST. The one ordering the whole site uses.

    Sorting lives here rather than in each caller because it did not, once. The table on
    /demos/vaults/ rendered the JSON's raw file order while /demos/vaults/llms.txt sorted by
    ordinal, so four vaults added by appending to the file sat at the BOTTOM of the human
    table and the TOP of the machine list, while that page claimed the two are generated
    from the same file and therefore cannot drift. They had drifted for four releases.

    `n` is a PERMANENT publication ordinal, not a row position: #1 is the first vault ever
    published here. That is what makes it the sort key, and the assertions below are what
    make it safe to rely on, they fail the build if the numbering stops being a total order
    or stops agreeing with the dates, rather than letting the order quietly rot again.
    """
    rows = json.load(open(os.path.join(ADMIN, 'content', 'vaults.json')))
    ns = [int(v['n']) for v in rows]
    dupes = sorted({n for n in ns if ns.count(n) > 1})
    assert not dupes, f'vaults.json: duplicate publication ordinals {dupes}'
    assert sorted(ns) == list(range(1, len(ns) + 1)), (f'vaults.json: ordinals must run 1..{len(ns)} with no gaps; got {sorted(ns)}')
    rows.sort(key=lambda v: -int(v['n']))
    dates = [v['published'] for v in rows]
    assert dates == sorted(dates, reverse=True), ('vaults.json: the publication ordinals disagree with the published dates, so "newest '
        'first" by # is not newest first by date. Renumber, or correct the date.')
    return rows


def _vault_shot(v, name, alt=''):
    """A vault screenshot as the site's runtime-filled figure, never a static <img src>,
    which the validator refuses because a relative src does not resolve when this page
    renders inside a vault. shots.js fills figure.shot[data-shot] on every page."""
    return (f'<figure class="shot hv-fig" data-shot="{name}" '
            f'data-dir="demos/vaults/{v["slug"]}/images/" data-alt="{alt}"></figure>')


def home_hero_vaults():
    """Four real vaults under the headline, before any explanation.

    The diagnosis (articles/proof-behind-the-claim) was that the homepage led with
    encryption, a property nobody can look at, while the twenty-five artefacts a
    stranger can open in one click sat two clicks away as a table. So the first thing
    under the hero is the proof: four vaults, a screenshot each, an open link. Which four
    is a `hero` field in vaults.json, so changing the front door is a data edit."""
    rows = _vaults()
    hero = sorted((v for v in rows if v.get('hero')), key=lambda v: v['hero'])
    cards = '\n'.join(f' <a class="hv-card rev" href="demos/vaults/{v["slug"]}/index.html">'
        f'{_vault_shot(v, v["hero_shot"], v["name"])}'
        f'<span class="hv-cat">{v["category"]}</span><span class="hv-sep">, </span><b>{v["name"]}</b><span class="hv-sep">, </span>'
        f'<span class="hv-what">{v["what"]}</span><span class="hv-sep">, </span><span class="hv-go">Open it &rarr;</span></a>'
        for v in hero)
    return (f' <div class="hv">\n{cards}\n </div>\n'
            f' <p class="hv-note">Four of <b>{len(rows)} published vaults</b>. Each opens with a read key '
            f'printed on its page, no account, nothing to install, and the server that stores it '
            f'cannot read it. <a href="demos/vaults/index.html">See all {len(rows)} &rarr;</a></p>')


def home_jobs_band():
    """Six vaults chosen by the JOB they do (hand over a report, give a talk) rather than
    the shape they are. The `job` / `why` / `job_shot` fields live in vaults.json."""
    order = ['pentest-report', 'aiuc-1-conformance', 'blackhat-eu-2025',
             'voicedebrief-pitch', 'agent-permission-games', 'risk-mandate']
    by = {v['slug']: v for v in _vaults() if v.get('job')}
    cards = '\n'.join(f' <a class="job rev" href="demos/vaults/{by[s]["slug"]}/index.html">'
        f'{_vault_shot(by[s], by[s]["job_shot"], by[s]["name"])}'
        f'<span><span class="job-verb">{by[s]["job"]}</span><span class="hv-sep">, </span><b>{by[s]["name"]}</b><span class="hv-sep">, </span>'
        f'<span class="job-why">{by[s]["why"]}</span><span class="hv-sep">, </span>'
        f'<span class="job-vault">vault <code>{by[s]["vault_id"]}</code> &middot; open it &rarr;</span></span></a>'
        for s in order if s in by)
    return f' <div class="jobs">\n{cards}\n </div>'


def home_team_band():
    """The collaboration story with its numbers, computed rather than typed so they
    cannot go stale: releases from VERSION_LOG, vaults from vaults.json, sites from the
    network directory, articles from the articles folder, asks from the briefs page."""
    briefs = open(os.path.join(ADMIN, 'content', 'docs', 'briefs', 'index.html')).read()
    asks = briefs.count('<h3>&larr;') + briefs.count('<h3>&rarr;') + briefs.count('<h3>←') + briefs.count('<h3>→')
    builds = briefs.count('<tr><td><a href="') - 0 # build-brief rows in the first table
    nums = [
        (len(VERSION_LOG), 'site releases, each verified live before it was called done'),
        (len(_vaults()), 'vaults published with a deliberately public read key'),
        (len(SITES), 'sibling sites on <code>*.sgit.ai</code>, one question each'),
        (asks, 'cross-team briefs filed or received, in the open'),
    ]
    tiles = '\n'.join(f' <div class="t rev"><span class="num">{n}</span><span class="lbl">{l}</span></div>'
                      for n, l in nums)
    return f""" <div class="team">
{tiles}
  </div>
  <div class="team-story">
    <div class="beat rev"><span class="k">1</span><span>A <a href="docs/briefs/vault-telemetry-append-lanes.html">build brief</a> was published here on a Saturday. Another agent read it and shipped <a href="demos/vaults/agent-permission-games/index.html">a vault from it</a> the same day.</span></div>
    <div class="beat rev"><span class="k">2</span><span>The team that owns the API reviewed that vault against the brief, found the brief wrong in two places, and <a href="docs/briefs/index.html">the correction now sits above the mistake</a>.</span></div>
    <div class="beat rev"><span class="k">3</span><span>One agent <a href="demos/vaults/aiuc-1-conformance/index.html">forked another agent's vault</a>, kept every byte, added a layer, and the original's tests still pass inside the fork.</span></div>
    <div class="beat rev"><span class="k">&rarr;</span><span>The record is the site itself: <a href="docs/briefs/index.html">the briefs</a>, <a href="case-studies/index.html">the case studies</a>, <a href="admin/versions.html">every release</a>. And the diagnosis that produced this homepage is <a href="articles/proof-behind-the-claim.html">an article, with the before pictures</a>.</span></div>
  </div>"""


# ---------------------------------------------------------------- team section
STATUS_LABEL = {'needs': 'Needs, only the author can supply', 'backlog': 'Backlog',
                'doing': 'Doing', 'review': 'Review', 'done': 'Done'}

def _role_by_slug():
    return {r['slug']: r for r in ROLES}

def team_roles_cards():
    """The roles grid on the team page, from admin/content/team/roles/*.md."""
    cards = '\n'.join(f' <a class="rolecard rev" href="roles/{r["slug"]}.html">'
        f'<span class="role-n">{r["order"]}</span><b>{r["title"]}</b>'
        f'<span class="hv-sep">, </span><span class="role-mission">{r["mission"]}</span>'
        f'<span class="hv-sep">, </span><span class="role-owns"><b>Owns</b> {r["owns"]}</span></a>'
        for r in ROLES)
    return f' <div class="roles">\n{cards}\n </div>'

def role_body(r):
    """One role page: identity table, then the file's own markdown."""
    by = _role_by_slug()
    others = ' &middot; '.join(f'<a href="{o["slug"]}.html">{o["title"]}</a>' for o in ROLES if o is not r)
    rows = [('Mission', r['mission']), ('Owns', r['owns']), ('Not responsible for', r['not'])]
    if r.get('files'): rows.append(('Works in', ' &middot; '.join(f'<code>{f.strip().replace("<","&lt;").replace(">","&gt;")}</code>' for f in r['files'].split(','))))
    if r.get('checks'): rows.append(('Checks it runs', r['checks']))
    ident = '\n'.join(f' <tr><th>{k}</th><td>{v}</td></tr>' for k, v in rows)
    mine = [i for i in ISSUES if i['role'] == r['slug'] and i['status'] != 'done']
    open_items = ('\n'.join(f'<li><b>{i["id"]}</b> &middot; <a href="../board.html#{i["id"]}">{i["title"]}</a> '
                            f'<span class="small dim">({i["status"]}, {i["priority"]})</span></li>' for i in mine)
                  or '<li class="dim">nothing open</li>')
    return f"""<main class="doc">
  <p class="crumb"><a href="../../index.html">Home</a> / <a href="../index.html">Team</a> / {r['title']}</p>
  <p class="eyebrow">Agentic role &middot; {r['order']} of {len(ROLES)}</p>
  <h1>{r['title']}</h1>
  <div class="tablewrap"><table class="ident">
{ident}
  </table></div>
{LOADER.md_to_html(r['body'], depth=2, where=r['where'])}
  <h2 id="open">On the board for this role</h2>
  <ul>{open_items}</ul>
  <p class="small dim" style="margin-top:2rem">Other roles: {others} &middot; <a href="../prompts.html">Starting prompts</a> &middot; <a href="../board.html">The board</a></p>
</main>"""

def team_board():
    """The kanban, from admin/content/team/issues/*.md. Five columns; a card is a file."""
    by = _role_by_slug()
    cols = []
    for st in ('needs', 'backlog', 'doing', 'review', 'done'):
        items = [i for i in ISSUES if i['status'] == st]
        cards = '\n'.join(f' <article class="kcard pr-{i["priority"]}" id="{i["id"]}">'
            f'<span class="kid">{i["id"]}</span><span class="hv-sep">, </span>'
            f'<b>{i["title"]}</b><span class="hv-sep">, </span>'
            f'<span class="kmeta"><a href="roles/{i["role"]}.html">{by.get(i["role"], {}).get("title", i["role"])}</a>'
            f' &middot; {i["priority"]} &middot; {i["opened"]}</span>'
            f'<div class="kbody">{LOADER.md_to_html(i["body"], depth=1, where=i["where"])}</div></article>'
            for i in items) or ' <p class="dim small">empty</p>'
        cols.append(f' <section class="kcol kcol-{st}"><h3>{STATUS_LABEL[st]} <span class="kcount">{len(items)}</span></h3>\n{cards}\n </section>')
    n_open = sum(1 for i in ISSUES if i['status'] not in ('done',))
    return (f' <p class="vt-count"><b>{len(ISSUES)} items</b>: {n_open} open, '
            f'{sum(1 for i in ISSUES if i["kind"] == "need" and i["status"] != "done")} of them waiting on the author. '
            f'Each card is a file under <code>admin/content/team/issues/</code>; moving a card is editing its <code>status</code> line.</p>\n'
            f' <div class="kanban">\n' + '\n'.join(cols) + '\n </div>')

def investors_traction():
    """Traction numbers computed from the site itself, the same rule as the team band:
    if a number here is wrong, the site is wrong somewhere else too."""
    briefs = open(os.path.join(ADMIN, 'content', 'docs', 'briefs', 'index.html')).read()
    asks = briefs.count('<h3>&larr;') + briefs.count('<h3>&rarr;') + briefs.count('<h3>←') + briefs.count('<h3>→')
    nums = [
        (len(VERSION_LOG), 'site releases since 14 August', 'each verified live before it was called done'),
        (len(_vaults()), 'vaults published with a public read key', 'every one audited first; findings on the page'),
        (len(SITES), 'sibling sites on *.sgit.ai', 'one question each, own repo, own history'),
        (len(ARTICLES) + len(UPDATES), 'articles and release notes', 'all with a markdown twin and an RSS feed'),
        (asks, 'cross-team briefs, in the open', 'two of them corrected this site'),
        ('~4,000', 'tests in the CLI, mutation-tested in CI', 'against a real server, no mocks'),
    ]
    tiles = '\n'.join(f' <div class="t rev"><span class="num">{n}</span><span class="lbl"><b>{l}</b><br>{d}</span></div>'
                      for n, l, d in nums)
    return f' <div class="team inv-nums">\n{tiles}\n </div>'


def network_chat_block():
    """The 'which of these is mine?' panel, and the catalogue it runs on.

    The catalogue is emitted from SITES, the same data the cards and the table are
    built from, so the answers cannot drift from the directory underneath them. The
    default tier needs no key and no network; see assets/network-chat.js for why."""
    cat = []
    for x in SITES:
        href = f'{x["slug"]}.html' if not x['listing'] else x['url']
        cat.append({
            'domain': x['domain'],
            'thesis': x['thesis'] or x['tagline'],
            'category': x['category'],
            'href': href,
            'external': bool(x['listing']),
            # one lowercase blob per site for the matcher to score against
            'hay': ' '.join([x['domain'].replace('.', ' '), x['thesis'], x['tagline'],
                             x['summary'], x['category'], ' '.join(x['tags']),
                             x['aliases']]),
        })
    egs = ['I have to sign off a risk',
           'my app needs to call an LLM safely',
           'I am drawing a graph',
           'how should I license my open source project']
    btns = ''.join(f'<button type="button" class="nc-eg">{e}</button>' for e in egs)
    return (' <div class="netchat" id="netchat">\n'
        ' <div class="nc-head"><b>Which of these is mine?</b>'
        '<span class="nc-mode">instant match &middot; no key, no network</span></div>\n'
        ' <div class="nc-log"><div class="nc-msg nc-bot"><p>Describe what you are trying to '
        'do and I will point at the site that takes it seriously. This runs in your browser '
        'against the catalogue on this page, no key needed, nothing sent anywhere.</p>'
        f'<div class="nc-egs">{btns}</div></div></div>\n'
        ' <form class="nc-form">\n'
        ' <input class="nc-input" type="text" autocomplete="off" '
        'placeholder="e.g. I need to give an AI agent an identity">\n'
        ' <button class="nc-send" type="submit">Ask</button>\n'
        ' </form>\n'
        ' <div class="nc-foot">\n'
        ' <button type="button" class="nc-keytoggle">Use my own LLM key</button>\n'
        ' <span class="small dim">Optional. Answers get more conversational; the matching '
        'does not get more correct.</span>\n'
        ' </div>\n'
        ' <div class="nc-keypanel" hidden>\n'
        ' <p class="small"><b>Bring your own key (OpenRouter).</b> It is stored in this '
        'browser only and sent only to <code>openrouter.ai</code>, never to sgit.ai, which '
        'is a static site with no server to send it to. <b>This page cannot protect it the way a '
        'vault app can</b>: with no host there is no permission floor, so the key lives in this '
        'page\'s origin. <a href="https://llms.sgit.ai" rel="noopener" target="_blank">llms.sgit.ai '
        '&#8599;</a> explains the difference, and <a href="../articles/chat-on-a-static-site.html">'
        'the plan</a> explains how we intend to remove the trade-off.</p>\n'
        ' <div class="nc-keyrow">\n'
        ' <input class="nc-key" type="password" autocomplete="off" placeholder="sk-or-v1-...">\n'
        ' <button type="button" class="nc-keysave">Save in this browser</button>\n'
        ' </div>\n'
        ' </div>\n'
        ' </div>\n'
        f' <script>window.__SGIT_SITES__ = {json.dumps(cat, ensure_ascii=False)};</script>\n'
        # Fetched and evaluated rather than <script src>: these pages also render inside a
        # vault, on a blob: origin where a relative script src does not resolve. The
        # validator enforces this, see the contract check.
        ' <script>\n'
        ' fetch("../assets/network-chat.js")\n'
        ' .then(function (r) { if (!r.ok) throw new Error(r.status); return r.text(); })\n'
        ' .then(function (t) { (0, eval)(t); })\n'
        ' .catch(function (e) { console.error("[network] chat failed to load:", e); });\n'
        ' </script>')


def network_index_body():
    """The sibling *.sgit.ai sites, a directory, not a list.

    This started at two entries and is now nineteen, which changes what the page is
    for: a reader arriving here does not want to read nineteen reviews, they want to
    be sent to the one that answers their question. So the page leads with questions,
    groups by what a site is *about*, and keeps the full table underneath for scanning.
    Everything below is derived from admin/content/sites/*.md."""
    live = [x for x in SITES if _site_live(x)]
    pending = [x for x in SITES if not _site_live(x)]
    deep = [x for x in SITES if not x['listing']]

    out = ['<main class="doc">',
           ' <h1>The sgit.ai network</h1>',
           ' <p class="lead">Nineteen focused sites on <code>*.sgit.ai</code>, each taking one '
           'question further than a section here could. They share this site\'s design and its '
           'discipline (sourced claims, a stated status, honest edges) and they publish their '
           'arguments <b>before</b> the things they describe exist, so the commitments stay '
           'checkable afterwards.</p>',
           f' <p class="small dim">{len(live)} live, {len(pending)} with the repository and '
           'subdomain in place but nothing published yet. Screenshots are of the real sites, '
           'captured on the date each entry gives.</p>']

    out.append(network_chat_block())

    # ---- the chooser. The point of the page: a question, and where it is answered.
    out += [' <h2 id="start">Start from what you need</h2>',
            ' <p>Each line is a question somebody actually arrives with, and the site that '
            'takes it seriously.</p>',
            ' <div class="chooser">']
    for q, dom, why in ASK:
        site = next((x for x in SITES if x['domain'] == dom), None)
        if not site:
            continue
        href = f'{site["slug"]}.html' if not site['listing'] else site['url']
        ext = '' if not site['listing'] else ' rel="noopener" target="_blank"'
        arrow = '' if not site['listing'] else ' &#8599;'
        out.append(f' <a class="ask" href="{href}"{ext}>'
                   f'<span class="ask-q">{q}</span>'
                   f'<span class="ask-a"><b>{dom}</b>{arrow} &middot; {why}</span></a>')
    out.append(' </div>')

    # ---- grouped cards
    order = ['Agents & AI', 'Risk & governance', 'Graphs & method',
             'Security & infrastructure', 'Business & publishing', 'Other']
    for cat in order:
        group = [x for x in SITES if x['category'] == cat]
        if not group:
            continue
        out.append(f' <h2 id="{re.sub(chr(92) + "W+", "-", cat.lower()).strip("-")}">{cat}</h2>')
        out.append(' <div class="netlist">')
        for x in sorted(group, key=lambda y: (not _site_live(y), y['domain'])):
            shot = (f'<figure class="shot net-shot" data-shot="{x["hero"]}" data-dir="images/" '
                    f'data-alt="{x["title"]}"></figure>') if x['hero'] else ''
            stage = f'<span class="chip">{x["stage"]}</span>' if x['stage'] else ''
            ver = f'<span class="chip">{x["seen_version"]}</span>' if x['seen_version'] else ''
            body = (f'<a class="net-main" href="{x["slug"]}.html">' if not x['listing']
                     else f'<a class="net-main" href="{x["url"]}" rel="noopener" target="_blank">')
            thesis = x['thesis'] or x['tagline']
            deeplink = (f' <a href="{x["slug"]}.html">What it argues &rarr;</a> &middot;'
                        if not x['listing'] else '')
            openlink = (f'<a href="{x["url"]}" rel="noopener" target="_blank">'
                        f'Open {_site_host(x)} &#8599;</a>' if _site_live(x)
                        else '<span class="dim">not published yet</span> &middot; '
                             f'<a href="{x["url"]}" rel="noopener" target="_blank">repo &#8599;</a>')
            out.append(f' <div class="netcard">\n'
                f' {body}\n'
                f' <b>{x["domain"]}</b>\n'
                f' <span class="net-tag">{thesis}</span>\n'
                f' <span class="net-sum">{x["summary"]}</span>\n'
                f' </a>\n'
                f' {shot}\n'
                f' <p class="small dim">{stage}{ver}{deeplink} {openlink}</p>\n'
                f' </div>')
        out.append(' </div>')

    # ---- the whole family, scannable
    out += [' <h2 id="all">Every site, at a glance</h2>',
            ' <div class="tablewrap"><table>',
            ' <tr><th>Site</th><th>What it argues</th><th>Area</th><th>Status</th><th></th></tr>']
    for x in sorted(SITES, key=lambda y: (not _site_live(y), y['domain'])):
        st = x['stage'] or ('live' if _site_live(x) else 'not published yet')
        v = f' &middot; <code>{x["seen_version"]}</code>' if x['seen_version'] else ''
        link = (f'<a href="{x["url"]}" rel="noopener" target="_blank">open &#8599;</a>'
                if _site_live(x) else
                f'<a href="{x["url"]}" rel="noopener" target="_blank">repo &#8599;</a>')
        name = (f'<a href="{x["slug"]}.html">{x["domain"]}</a>' if not x['listing']
                else f'<b>{x["domain"]}</b>')
        out.append(f' <tr><td>{name}</td><td>{x["thesis"] or x["tagline"]}</td>'
                   f'<td class="small">{x["category"]}</td><td class="small">{st}{v}</td>'
                   f'<td>{link}</td></tr>')
    out += [' </table></div>']

    out += [f' <h2>Read one in full</h2>',
            ' <p>Four have a full write-up here, what the site argues, where it is honest '
            'about its limits, and why it is relevant to sgit:</p>',
            ' <ul>'] + [
            f' <li><a href="{x["slug"]}.html"><b>{x["domain"]}</b></a>, {x["tagline"]}</li>'
            for x in sorted(deep, key=lambda y: y['domain'])] + [' </ul>']

    out += [' <h2>Why they are separate sites</h2>',
            ' <p>Each one is an argument that needs room and a reader who arrived for it. '
            'Splitting them out keeps this site about sgit while letting each question be '
            'pursued properly, and gives each its own version history, release cadence and '
            'repository. They are built from the same generator and hold to the same rules, so '
            'a reader moving between them is not changing register.</p>',
            ' <p class="small dim">This is also the refactor it looks like: material that '
            'would have made sgit.ai sprawl now has a better home, and this page is the index '
            'back into it. Adding the twentieth site is writing one markdown file.</p>',
            '</main>']
    return '\n'.join(out)


def _site_host(x):
    return x['url'].split('://', 1)[-1].rstrip('/')


def _site_live(x):
    """True once the site answers on its own subdomain."""
    return x['url'].rstrip('/') == f'https://{x["domain"]}'


def site_body(x):
    ver = f' &middot; <code>{x["seen_version"]}</code> when captured' if x['seen_version'] else ''
    repo = (f' &middot; <a href="https://github.com/SGit-AI/{x["repo"]}" rel="noopener" '
            f'target="_blank">{x["repo"]} &#8599;</a>') if x['repo'] else ''
    # A site can be finished before its subdomain resolves. Say which address works
    # rather than shipping a link that fails, and keep the intended one visible so the
    # entry does not need rewriting when DNS lands.
    pending = ('' if _site_live(x) else
               f' <div class="note"><b><code>{x["domain"]}</code> does not resolve yet.</b> '
               f'The site is live and complete at its origin, linked below; the subdomain is '
               f'being pointed at it. Everything on this page was read there.</div>\n')
    return ('<main class="doc">\n'
            f' <p class="crumb"><a href="../index.html">Home</a> / '
            f'<a href="index.html">Network</a> / {x["domain"]}</p>\n'
            f' <h1>{x["domain"]}</h1>\n'
            f' <p class="lead">{x["tagline"]}</p>\n'
            f' <p class="small dim">Screenshots captured {x["observed"]}{ver}{repo}</p>\n'
            + pending +
            f' <p><a class="btn" href="{x["url"]}" rel="noopener" target="_blank">'
            f'Open {_site_host(x)} &#8599;</a></p>\n'
            + LOADER.md_to_html(x['body'], depth=1, where=x['where'])
            + '\n <p class="small dim" style="margin-top:2rem">'
              '<a href="index.html">&larr; All network sites</a></p>\n'
            '</main>')


def _short(title, n=52):
    """A title cut at the colon or the first full stop, or at a word boundary before n
    characters, for the dense lists where the full title would be a paragraph."""
    t = re.split(r':|\.\s', title)[0].strip()
    if len(t) <= n:
        return t
    cut = t[:n].rsplit(' ', 1)[0].rstrip(',;')
    return cut + '…'


SUBSCRIBE_TO = 'agent@riskmandate.ai'


def subscribe_block(pre='', compact=False):
    """Subscribe to new articles. The reader types an address; assets/subscribe.js encrypts it in
    the browser to the key of the agent that manages the list and drops it into the write-only
    `subscribe` append lane of the subscribe vault (the lane, the key and the vault id are public,
    in /.well-known/sgit-subscribe.json; the briefing for the agent is docs/briefs/subscribe-lane-agent-brief).
    Without JavaScript, or if any step fails, the same request is offered as a plain email to
    the same agent, so nothing is lost."""
    from urllib.parse import quote
    href = (f'mailto:{SUBSCRIBE_TO}?subject={quote("Subscribe: SGit Newsroom newsletter")}'
            f'&amp;body={quote("Please add me to the SGit Newsroom newsletter.")}')
    # From v0.6.89 the subscription is to the newsletter, not to every article: an issue has the
    # context a single article does not, and an email per article would be noise.
    lead = 'Get the SGit Newsroom newsletter' if not compact else 'Get the next issue by email'
    return (f'<aside class="subscribe" id="subscribe" aria-label="Subscribe to the newsletter">'
            f'<p><b>{lead}.</b> One issue a week or so: what was published, what it adds up to, what is worth your time. '
            f'A personalised issue, picked for what you care about, comes next. Your address is encrypted in your browser to the key of the agent that '
            f'manages the list and dropped into a write-only lane on an encrypted vault. '
            f'<a href="{pre}docs/briefs/subscribe-lane-agent-brief.html">How it works</a>.</p>'
            f'<form class="subform" data-subscribe novalidate>'
            f'<label class="sr" for="sub-email">Email address</label>'
            f'<input id="sub-email" name="email" type="email" autocomplete="email" placeholder="you@example.com" required>'
            f'<label class="sr" for="sub-name">Name, optional</label>'
            f'<input id="sub-name" name="name" type="text" autocomplete="name" placeholder="Name (optional)">'
            f'<span class="hp" aria-hidden="true"><label for="sub-web">Website</label>'
            f'<input id="sub-web" name="website" type="text" tabindex="-1" autocomplete="off"></span>'
            f'<button class="subbtn" type="submit">Subscribe</button>'
            f'<label class="consent"><input type="checkbox" name="consent"> <span>I am happy for the agent at '
            f'{SUBSCRIBE_TO}, which runs this list for sgit.ai, to keep this address in a private vault and email '
            f'me the SGit Newsroom newsletter. I can ask to be removed by replying to any message.</span></label>'
            f'<p class="sub-status small" aria-live="polite"></p>'
            f'<a class="sub-mailto small" href="{href}" hidden>Send it as an email instead &rarr;</a>'
            f'</form>'
            f'<div class="sub-done" hidden><b>Sent, encrypted, into the vault.</b> The lane answers only '
            f'<code>ok</code>, by design, so there is no receipt. If nothing arrives, email '
            f'<a href="mailto:{SUBSCRIBE_TO}">{SUBSCRIBE_TO}</a>.</div>'
            f'<noscript><p class="small">This form needs JavaScript. Email '
            f'<a href="{href}">{SUBSCRIBE_TO}</a> instead.</p></noscript>'
            f'<p class="small dim">Prefer a feed? <a href="{pre}articles/feed.xml">articles/feed.xml</a> carries the '
            f'articles and the desk notes; agents can read <a href="{pre}newsroom/wire.json">the wire</a>.</p></aside>')


# ---- other ways to read an article. Written once as prose with its evidence, an article can
# carry views extracted from it by the desk's readers (admin/content/newsroom/roles/): the
# Explainer's two minutes, the Historian's place in the arc, the Storyteller's deck, the
# Cartographer's map and the Librarian's catalogue. Each lives in
# admin/content/articles/views/<slug>/ and every one is optional; an article without the folder
# renders exactly as before. Rendered as <details>, so it works without JavaScript and prints.
VIEWS_DIR = os.path.join(ADMIN, 'content', 'articles', 'views')
VIEW_KIND_LABELS = [('claim', 'Claims'), ('evidence', 'Evidence'), ('data-point', 'Data points'),
                    ('fact', 'Facts'), ('hypothesis', 'Hypotheses'), ('question', 'Open questions'),
                    ('definition', 'Definitions'), ('method', 'Methods'), ('decision', 'Decisions'),
                    ('limitation', 'Limitations'), ('example', 'Examples'), ('source', 'Sources'),
                    ('artefact', 'Artefacts'), ('entity', 'Names')]


def _view_text(t):
    """Escape a catalogue line; a token with angle brackets (mcp__<server>__<tool>) becomes code,
    so the markdown twin carries it in backticks rather than as something that looks like HTML."""
    return re.sub(r'\S*&lt;\S+?&gt;\S*', lambda m: f'<code>{m.group(0)}</code>', _esc(t))


def article_views(slug):
    d = os.path.join(VIEWS_DIR, slug)
    if not os.path.isdir(d):
        return None
    def read(name):
        p = os.path.join(d, name)
        return open(p).read() if os.path.exists(p) else None
    v = {'explainer': read('explainer.md'), 'historian': read('historian.md')}
    for k in ('catalog', 'deck', 'meta'):
        t = read(k + '.json')
        v[k] = json.loads(t) if t else None
    return v


def article_views_block(a):
    v = article_views(a['slug'])
    if not v:
        return ''
    where = f'articles/views/{a["slug"]}'
    meta = v.get('meta') or {}
    def md_view(md):
        # drop the view's own H1: the summary line already names it
        body = '\n'.join(l for l in md.splitlines() if not l.startswith('# '))
        return LOADER.md_to_html(body, depth=1, where=where)
    def first_h1(md, fallback):
        return next((l[2:].strip() for l in md.splitlines() if l.startswith('# ')), fallback)
    parts = []
    if v['explainer']:
        parts.append(('two-minutes', 'In two minutes', 'Explainer', first_h1(v['explainer'], ''),
                      md_view(v['explainer']), True))
    if v['historian']:
        parts.append(('arc', 'In the arc', 'Historian', 'What it added, and where it sits',
                      md_view(v['historian']), False))
    if v['deck']:
        dk = v['deck']
        n = len(dk['slides'])
        figs = ''.join(
            f'<figure class="shot" data-shot="slide-{i:02d}.webp" data-dir="views/{a["slug"]}/" '
            f'data-alt="Slide {i} of {n}"><figcaption>{i} / {n}</figcaption></figure>'
            for i in range(1, n + 1))
        pdf = (f' <a href="views/{a["slug"]}/{a["slug"]}.pdf">Download the deck as a PDF</a> (one page '
               f'per slide, ready for a LinkedIn document post).' if meta.get('pdf') else '')
        parts.append(('pictures', 'In pictures', 'Storyteller', f'{n} slides',
                      f'<div class="vdeck">{figs}</div><p class="small dim">Swipe or scroll sideways.{pdf}</p>',
                      False))
    if meta.get('map'):
        parts.append(('map', 'On the map', 'Cartographer', meta.get('map_caption', 'The argument as a map'),
                      f'<figure class="shot" data-shot="map.webp" data-dir="views/{a["slug"]}/" '
                      f'data-alt="{_esc(meta.get("map_caption", ""))}"><figcaption>{_esc(meta.get("map_caption", ""))}'
                      f'</figcaption></figure>', False))
    if v['catalog']:
        cat = v['catalog']
        items = cat.get('items', [])
        groups = []
        for kind, label in VIEW_KIND_LABELS:
            its = [i for i in items if i.get('kind') == kind]
            if not its:
                continue
            lis = ''.join(f'<li>{_view_text(i["text"])} <span class="dim">({_esc(i.get("section", ""))})</span></li>'
                          for i in its)
            groups.append(f'<details class="vkind"><summary>{label}: <span class="vn">{len(its)}</span></summary><ul>{lis}</ul></details>')
        flags = cat.get('flags') or []
        flag_html = (f'<details class="vkind vflags"><summary>Flagged for the author: <span class="vn">{len(flags)}</span></summary>'
                     '<ul>' + ''.join(f'<li>{_view_text(f)}</li>' for f in flags) + '</ul></details>') if flags else ''
        parts.append(('catalogue', 'The catalogue', 'Librarian',
                      f'{len(items)} items, each anchored to a sentence of the article',
                      '<p class="small dim">Everything the article contains, by kind. Every item was extracted '
                      'with the exact sentence it came from, and a script checked each one against the article.</p>'
                      + ''.join(groups) + flag_html, False))
    if not parts:
        return ''
    how = LOADER._link_href(meta.get('about', '/articles/one-article-five-readers.html'), 1, where)
    # one line when closed: what is inside, so a reader can decide without opening it
    cat = v.get('catalog') or {}
    n_items, n_flags = len(cat.get('items', [])), len(cat.get('flags') or [])
    bits = {'two-minutes': 'two minutes', 'arc': 'the arc',
            'pictures': f'{len((v.get("deck") or {}).get("slides", []))} slides', 'map': 'a map',
            'catalogue': f'{n_items} catalogued items' + (f', {n_flags} flagged' if n_flags else '')}
    line = ' &middot; '.join(bits[p[0]] for p in parts if p[0] in bits)
    others = [s_ for s_ in sorted(os.listdir(VIEWS_DIR)) if s_ != a['slug'] and s_ in VERSIONS]
    others.sort(key=lambda s_: next((x['date'] + x['time'] for x in ARTICLES if x['slug'] == s_), ''))
    of = (f' Read from article {meta["of_version"]}' + (f' on {meta["date"]}' if meta.get('date') else '') +
          '; the views are not part of the article\'s text and do not change its version.') if meta.get('of_version') else ''
    out = ['\n<details class="aviews" id="views">\n <summary><span class="aviews-t">Five readers</span>'
           f'<span class="aviews-s">{line}</span></summary>\n'
           f' <p class="small dim">This article read again, after it was written, by five of the desk\'s readers: the Explainer, '
           f'the Historian, the Storyteller, the Cartographer and the Librarian. None adds a claim the article does not make.{of} '
           f'<a href="{how}">How the five readers work</a>.</p>\n']
    for vid, title, role, sub, body, open_ in parts:
        out.append(f' <details class="aview" id="view-{vid}"><summary>'
                   f'<span class="aview-t">{title}</span><span class="aview-r"><span class="sr"> (</span>{role}<span class="sr">): </span></span>'
                   f'<span class="aview-s">{_esc(sub)}</span></summary>\n  <div class="aview-b">{body}</div>\n </details>\n')
    if others:
        links = ''.join(f'<li><a href="{s_}.html#views">{_esc(next(x["title"] for x in ARTICLES if x["slug"] == s_).split(":")[0])}</a></li>'
                        for s_ in others)
        out.append(f' <div class="aviews-more"><p class="small"><b>The five readers have also read:</b></p><ul class="small">{links}</ul>'
                   f'<p class="small dim">Every view, the role files and the tools are in the '
                   f'<a href="../demos/vaults/article-views/index.html">Article Views vault</a>.</p></div>\n')
    out.append('</details>\n')
    return ''.join(out)


def _vfile(v):
    return v['version'] + '.html'


def article_versions_body(a):
    """articles/versions/<slug>.html: every published version of one article, with what changed."""
    vs = VERSIONS[a['slug']]
    src = open(os.path.join(ADMIN, 'content', 'articles', a['slug'] + '.md')).read()
    rows, prev_text = [], None
    for i, v in enumerate(vs):
        text = AV.text_at(ROOT, v, src)
        if prev_text is None:
            change = 'first published'
            link = f'<a href="../{a["slug"]}.html">read it</a>' if len(vs) == 1 else ''
        else:
            _, st = AV.diff(prev_text, text)
            change = (f'+{st["words_added"]:,} / &minus;{st["words_removed"]:,} words; '
                      f'{st["changed"]} changed, {st["added"]} added, {st["removed"]} removed paragraphs'
                      if v['kind'] != 'patch' else 'front matter only (the text is unchanged)')
            link = f'<a href="{a["slug"]}/{_vfile(v)}">what changed</a>'
        if i == len(vs) - 1:
            link = (link + ' &middot; ' if link and i else '') + f'<a href="../{a["slug"]}.html">current</a>'
        commit = (f'<a href="https://github.com/SGit-AI/SGit-AI__Website/commit/{v["full"]}" rel="noopener" '
                  f'target="_blank"><code style="white-space:nowrap">{v["commit"]}</code></a>' if v.get('full') else 'this release')
        rows.append(f'<tr><td style="white-space:nowrap"><b>{v["version"]}</b></td><td style="white-space:nowrap">{v["date"]}</td><td>{v["kind"]}</td>'
                    f'<td>{v["words"]:,}</td><td>{change}</td><td>{commit}</td><td>{link}</td></tr>')
        prev_text = text
    n = len(vs)
    return ('<main class="doc">\n'
            f' <p class="crumb"><a href="../../index.html">Home</a> / <a href="../index.html">Articles</a> / '
            f'<a href="../{a["slug"]}.html">{_esc(a["title"].split(":")[0])}</a> / Versions</p>\n'
            f' <h1>Versions of: {_esc(a["title"].split(":")[0])}</h1>\n'
            f' <p class="lead">{n} published version{"s" if n != 1 else ""} of <a href="../{a["slug"]}.html">{_esc(a["title"])}</a>. '
            f'The current one is <b>{vs[-1]["version"]}</b>.</p>\n'
            ' <p class="small dim">v1.0.0 is the article as first published. A change to its text (title, abstract or body) '
            'is the next minor version, v1.1.0; a change only to its other details (a LinkedIn link, the release it belongs to) '
            'is the next patch, v1.0.1. The list is read from the site\'s git history at every build, so it cannot be edited '
            'by hand and cannot leave a version out. Views extracted from an article, such as the Five readers, are not part '
            'of its text and do not change its version.</p>\n'
            ' <table><tr><th>Version</th><th>Date</th><th>Kind</th><th>Words</th><th>Change from the version before</th>'
            '<th>Commit</th><th></th></tr>\n  ' + '\n  '.join(reversed(rows)) + '\n </table>\n'
            f' <p class="small dim" style="margin-top:2rem"><a href="../{a["slug"]}.html">&larr; Back to the article</a> &middot; '
            '<a href="../index.html">All articles</a></p>\n</main>')


def article_version_diff_body(a, i):
    """articles/versions/<slug>/<version>.html: the change from version i-1 to version i."""
    vs = VERSIONS[a['slug']]
    src = open(os.path.join(ADMIN, 'content', 'articles', a['slug'] + '.md')).read()
    old, new = AV.text_at(ROOT, vs[i - 1], src), AV.text_at(ROOT, vs[i], src)
    blocks, st = AV.diff(old, new, up='../../..')
    t = _esc(a['title'].split(':')[0])
    label = lambda v: f'{v["version"]} ({v["date"]}' + (f', <code>{v["commit"]}</code>)' if v.get('commit') else ', this release)')
    nav = []
    if i > 1: nav.append(f'<a href="{_vfile(vs[i - 1])}">&larr; {vs[i - 1]["version"]}</a>')
    nav.append(f'<a href="../{a["slug"]}.html">all versions</a>')
    if i < len(vs) - 1: nav.append(f'<a href="{_vfile(vs[i + 1])}">{vs[i + 1]["version"]} &rarr;</a>')
    if vs[i]['kind'] == 'patch':
        blocks = ['<p class="diff-same">The text is identical. This version changed only the article\'s other details, '
                  'such as a link or the release it belongs to.</p>']
    return ('<main class="doc diff">\n'
            f' <p class="crumb"><a href="../../../index.html">Home</a> / <a href="../../index.html">Articles</a> / '
            f'<a href="../../{a["slug"]}.html">{t}</a> / <a href="../{a["slug"]}.html">Versions</a> / {vs[i]["version"]}</p>\n'
            f' <h1>{t}: what changed in {vs[i]["version"]}</h1>\n'
            f' <p class="lead">From {label(vs[i - 1])} to {label(vs[i])}, paragraph by paragraph.</p>\n'
            f' <p class="small dim">{st["added"]} paragraph{"s" if st["added"] != 1 else ""} added, {st["removed"]} removed, '
            f'{st["changed"]} changed in place, {st["same"]} unchanged. About {st["words_added"]:,} words added and '
            f'{st["words_removed"]:,} removed. Insertions are marked like <ins>this</ins>, deletions like <del>this</del>; '
            'unchanged runs are folded to one line; figures appear as their file names.</p>\n'
            f' {AV.DIFF_CSS}\n'
            f' <p class="small">{" &middot; ".join(nav)}</p>\n'
            + '\n'.join(' ' + b for b in blocks) +
            f'\n <p class="small" style="margin-top:2rem">{" &middot; ".join(nav)}</p>\n</main>')


def article_body(a):
    ver = (f' &middot; <a href="../admin/versions.html" title="The site release it was published in">site {a["version"]}</a>' if a['version'] else '')
    # The byline is the first thing after the title, because an article written in the
    # first person without a name on it is a provenance failure on a site about provenance.
    # author_url is either absolute (external profile) or site-root relative (a page here,
    # which is the preferred form: one profile page that links out to everything else).
    if a['author']:
        u = a['author_url']
        href, extra = (u, ' rel="author noopener" target="_blank"') if u.startswith('http') else ('../' + u, ' rel="author"')
        by = f'By <a href="{href}"{extra}>{a["author"]}</a> &middot; '
    else:
        by = ''
    return ('<main class="doc">\n'
            f' <p class="crumb"><a href="../index.html">Home</a> / '
            f'<a href="index.html">Articles</a> / {a["title"]}</p>\n'
            f' <h1>{a["title"]}</h1>\n'
            f' <p class="small dim">{by}{a["date"]}'
            + (f' &middot; updated {a["updated"]}' if a.get('updated') else '')
            + f' &middot; <a href="versions/{a["slug"]}.html" title="Every published version of this article, and what changed">article {a["article_version"]}'
            + (f', {len(VERSIONS[a["slug"]])} versions' if len(VERSIONS[a['slug']]) > 1 else '') + '</a>' + ver
            + (f' &middot; <a href="{_esc(a["linkedin"])}" rel="noopener" target="_blank">also on LinkedIn &#8599;</a>'
               if a.get('linkedin') else '')
            + (f' &middot; {_chips(a["tags"])}' if a['tags'] else '') + '</p>\n'
            f' <p class="abstract"><em><b>Abstract:</b> {a["summary"]}</em></p>\n'
            + article_views_block(a)
            + LOADER.md_to_html(a['body'], depth=1, where=a['where'])
            + article_threads_block(a)
            + article_desk_block(a)
            + linkedin_kit(a['slug'], '../')
            + '\n ' + subscribe_teaser('../', 'Want the next issue by email')
            + '\n <p class="small dim" style="margin-top:2rem">'
              '<a href="index.html">&larr; All articles</a></p>\n'
            '</main>')


# ============================================================ the newsroom
# The editorial layer over the articles (admin/build/newsroom.py has the model and the rules).
# Publishing stays adding one file: an article is live, in Latest, the archive, the feed and
# the wire as soon as it exists. Placement (the lead, the highlights, the homepage band, the
# featured collections) is the Editor's, in admin/content/newsroom/front.json, and nobody
# else's. Everything below renders from those files; nothing on these pages is hand-listed.
from newsroom import Newsroom, NOTE_KINDS, BOARD_STATUS

NEWS = Newsroom(LOADER, os.path.join(ADMIN, 'content'), ARTICLES)
NOTES_BY_SLUG = {n['slug']: n for n in NEWS.notes}
NOTES_CITING = {a['slug']: [n for n in NEWS.notes if a['slug'] in n['cites']] for a in ARTICLES}
COLLECTIONS_OF = {a['slug']: [c for c in NEWS.collections if a['slug'] in c['slugs']] for a in ARTICLES}
for _lvl, _where, _text in NEWS.findings:
    print(f'newsroom {_lvl}: {_where}: {_text}')


def _plain(md):
    """Markdown reduced to its words, for checking that a quote is really in an article:
    links to their text, emphasis and code marks dropped, quotes straightened, whitespace
    collapsed."""
    s = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', md)
    s = re.sub(r'[*`]', '', s)
    s = s.replace('“', '"').replace('”', '"').replace('’', "'").replace('‘', "'")
    return re.sub(r'\s+', ' ', s).strip()


def _quote_html(slug, text, root, why='', src=True):
    # The source is a paragraph AFTER the blockquote, not a <cite> inside it: pasted into
    # LinkedIn's editor, a blockquote keeps its first paragraph and drops the rest, and the
    # attribution vanished with it (Issue 1, 7 October).
    a = BY_SLUG[slug]
    if not src:
        return f'<blockquote class="dquote"><p>{_esc(text)}</p></blockquote>'
    return (f'<blockquote class="dquote"><p>{_esc(text)}</p></blockquote>'
            f'<p class="dquote-src">From <a href="{root}articles/{slug}.html">{_esc(a["title"])}</a>'
            + (f'. {_esc(why)}' if why else '') + '</p>')


def _range_list(lo, hi, root):
    xs = [a for a in ARTICLES if lo <= a['date'] <= hi]
    items = ''.join(f'<li><span class="drange-date">{a["date"]}</span> <a href="{root}articles/{a["slug"]}.html">'
                    f'{_esc(a["title"])}</a><span class="dim"> {_esc(art_teaser(a))}</span></li>' for a in xs)
    return (f'<div class="drange"><p class="drange-head"><b>{number_words(len(xs)).capitalize()} article'
            f'{"s" if len(xs) != 1 else ""}</b> published from {lo} to {hi}, newest first, each with the one-sentence '
            f'teaser from its graph.</p><ol>{items}</ol></div>')


# Images a desk page used, in order, keyed by the page's source file: the LinkedIn kit lists them
# for upload (LinkedIn does not carry images in a paste), and the banner manifest renders the collages.
DESK_MEDIA = {}
COLLAGE_DIR = 'articles/banners/collages'


def _repo_img(path, where):
    path = path.strip().lstrip('/')
    if not os.path.exists(os.path.join(ROOT, path)):
        raise Content_Error(f'{where}: image {path!r} does not exist')
    return path


def desk_md(body, depth, where, quote_src=True):
    """Article markdown, plus four directives that make a short note out of the data the
    articles already carry. A quote is checked against the article's text at build time,
    so a note cannot misquote, and when the article changes the build says so."""
    root = '../' * depth
    out, buf = [], []
    covers, listed = [], []    # !covers ranges, and every slug named by a !list

    out_md_tail = []           # the markdown flushed just before the current directive

    def flush():
        if buf:
            out_md_tail[:] = buf[-6:]
            out.append(LOADER.md_to_html('\n'.join(buf), depth=depth, where=where))
            buf.clear()

    for line in body.split('\n'):
        m_q = re.match(r'^!quote\s+([a-z0-9\-]+)\s*(?:\|\s*(.+?)|#(\d+))\s*$', line)
        m_a = re.match(r'^!article\s+([a-z0-9\-]+)\s*$', line)
        m_r = re.match(r'^!articles\s+(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})\s*$', line)
        m_l = re.match(r'^!list\s+(.+)$', line)
        m_f = re.match(r'^!figure\s+([^|]+?)\s*(?:\|\s*(.*))?$', line)
        m_g = re.match(r'^!collage\s+([a-z0-9-]+)\s*\|\s*([^|]+?)\s*\|\s*(.+)$', line)
        if m_f or m_g:
            flush()
            media = DESK_MEDIA.setdefault(where, {'items': [], 'collages': []})
            if m_f:
                # !figure <repo path> | caption: one image from an article or a vault, path from the repo root
                src = _repo_img(m_f.group(1), where); cap = (m_f.group(2) or '').strip()
                media['items'].append({'src': src, 'caption': cap})
                out.append(f'<figure class="shot" data-shot="{os.path.basename(src)}" data-dir="{root}{os.path.dirname(src)}/" '
                           f'data-alt="{_esc(cap)}"><figcaption>{LOADER.inline(cap, depth, where)}</figcaption></figure>')
            else:
                # !collage <name> | img :: label, img :: label | caption: several images as one picture,
                # rendered by make_banners.mjs into articles/banners/collages/<page>-<name>.jpg
                items = []
                for part in m_g.group(2).split(','):
                    src, _, label = part.partition('::')
                    items.append({'src': _repo_img(src, where), 'label': label.strip()})
                if not 2 <= len(items) <= 6:
                    raise Content_Error(f'{where}: a collage takes two to six images')
                stem = os.path.splitext(os.path.basename(where))[0]
                out_path = f'{COLLAGE_DIR}/{stem}-{m_g.group(1)}.jpg'
                cap = m_g.group(3).strip()
                media['collages'].append({'name': m_g.group(1), 'items': items, 'caption': cap, 'out': out_path})
                media['items'].append({'src': out_path, 'caption': cap})
                out.append(f'<figure class="shot shot--collage" data-shot="{os.path.basename(out_path)}" data-dir="{root}{COLLAGE_DIR}/" '
                           f'data-alt="{_esc(cap)}"><figcaption>{LOADER.inline(cap, depth, where)}</figcaption></figure>')
            continue
        m_c = re.match(r'^!covers\s+(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})\s*$', line)
        if m_l or m_c:
            flush()
            if m_c:
                covers.append((m_c.group(1), m_c.group(2)))
                n = len([a for a in ARTICLES if m_c.group(1) <= a['date'] <= m_c.group(2)])
                out.append(f'<p class="drange-head">{number_words(n).capitalize()} article{"s" if n != 1 else ""} '
                           + (f'were published on {m_c.group(1)}' if m_c.group(1) == m_c.group(2) else f'were published from {m_c.group(1)} to {m_c.group(2)}')
                           + ', grouped below by what they are about.</p>')
                continue
            slugs = [x.strip() for x in m_l.group(1).split(',') if x.strip()]
            for x in slugs:
                if x not in BY_SLUG:
                    raise Content_Error(f'{where}: !list names {x!r}, which is not a published article')
            listed.extend(slugs)
            out.append('<ul class="dlist">' + ''.join(
                f'<li><a href="{root}articles/{x}.html"><b>{_esc(BY_SLUG[x]["title"])}</b></a>. {_esc(art_teaser(BY_SLUG[x]))}</li>'
                for x in slugs) + '</ul>')
            continue
        if not (m_q or m_a or m_r):
            buf.append(line)
            continue
        flush()
        if m_r:
            out.append(_range_list(m_r.group(1), m_r.group(2), root))
            continue
        slug = (m_q or m_a).group(1)
        if slug not in BY_SLUG:
            raise Content_Error(f'{where}: {line.split()[0]} names {slug!r}, which is not a published article')
        if m_a:
            out.append('<div class="agrid dcard">' + art_card(BY_SLUG[slug], pre=root + 'articles/') + '</div>')
        elif m_q.group(2):
            text = m_q.group(2).strip()
            # word for word, except that a quote may capitalise its first letter when it starts
            # mid-sentence in the article, as quotations conventionally do
            q, body = _plain(text), _plain(BY_SLUG[slug]['body'])
            if q not in body and (q[:1].swapcase() + q[1:]) not in body:
                raise Content_Error(f'{where}: the quote is not in {slug}.md word for word: {text[:80]!r}')
            if not quote_src:
                # A newsletter quote carries no "From ..." line (it pastes badly into LinkedIn, and
                # reads as noise): the paragraph before it must link the article instead, so the
                # source is still one click away. Checked here so it stays true.
                prev = '\n'.join(l for l in out_md_tail if l.strip())
                if f'/articles/{slug}.html' not in prev:
                    raise Content_Error(f'{where}: the paragraph before the quote from {slug} must link to that article')
            out.append(_quote_html(slug, text, root, src=quote_src))
        else:
            qs = GRAPHS.get(slug, {}).get('quotes', [])
            n = int(m_q.group(3))
            if not 1 <= n <= len(qs):
                raise Content_Error(f'{where}: {slug} has {len(qs)} quotes in its graph; #{n} does not exist')
            out.append(_quote_html(slug, qs[n - 1]['text'], root, qs[n - 1].get('why', '')))
    flush()
    # !covers is a promise: every article in the range appears in one of the !list blocks
    for lo, hi in covers:
        missing = [a['slug'] for a in ARTICLES if lo <= a['date'] <= hi and a['slug'] not in listed]
        if missing:
            raise Content_Error(f'{where}: !covers {lo}..{hi} but no !list names: {", ".join(missing)}')
    return '\n'.join(out)


# ---------------------------------------------------------------- shared pieces

_CUR = ' aria-current="page"'


def _kicker(s):
    return f'<span class="fkick">{_esc(s)}</span>' if s else ''


def _note_teaser(n, root, show_kind=True):
    """A desk note as a row: kind, title, summary. A nugget shows its first quote instead,
    because the quote is the point of a nugget."""
    q = re.search(r'^!quote\s+([a-z0-9\-]+)\s*\|\s*(.+?)\s*$', n['body'], re.M)
    lead = (f'<span class="dnote-q">{_esc(q.group(2))}</span>' if n['kind'] == 'nugget' and q
            else f'<span class="dnote-sum">{_esc(n["summary"])}</span>')
    kind = f'<span class="fkick">{NOTE_KINDS[n["kind"]]}</span>' if show_kind else ''
    return (f'<a class="dnote dnote-{n["kind"]}" href="{root}articles/desk/{n["slug"]}.html">'
            f'<span class="dnote-meta">{kind}<span class="acard-date">{n["date"]}</span>'
            f'<span class="dnote-by">{_esc(n["role"]).capitalize()}</span></span>'
            f'<b>{_esc(n["title"])}</b>{lead}</a>')


def _collection_tile(c, root):
    thumbs = ''.join(f'<figure class="shot cardshot" data-shot="{art_card_img(BY_SLUG[s])}" '
                     f'data-dir="{root}articles/cards/" data-alt="{_esc(BY_SLUG[s]["title"])}"></figure>'
                     for s in c['slugs'][:3])
    return (f'<a class="ctile" href="{root}articles/collections/{c["id"]}.html">'
            f'<span class="ctile-thumbs">{thumbs}</span>'
            f'<span class="fkick">Collection &middot; {len(c["slugs"])} articles</span>'
            f'<b>{_esc(c["title"])}</b><span class="ctile-dek">{_esc(c["dek"])}</span></a>')


def _lead_block(slot, root, big=True):
    a = BY_SLUG[slot['slug']]
    g = GRAPHS.get(a['slug'], {})
    fig = (f'<figure class="shot cardshot" data-shot="{art_card_img(a)}" data-dir="{root}articles/cards/" '
           f'data-alt="{_esc(a["title"])}"></figure>')
    why = f'<span class="flead-why"><b>Why it leads.</b> {_esc(slot["why"])}</span>' if slot.get('why') else ''
    return (f'<a class="flead{" big" if big else ""}" href="{root}articles/{a["slug"]}.html">{fig}'
            f'<span class="flead-text"><span class="acard-meta">{_kicker(slot.get("kicker") or "Lead")}'
            f'<span class="acard-date">{a["date"]}</span>{_topic_chips(art_topics(a)[:1])}</span>'
            f'<b>{_esc(a["title"])}</b>'
            f'<span class="flead-teaser">{_esc(art_teaser(a))}</span>{why}'
            f'<span class="artcard-go">Read it &rarr;</span></span></a>')


def _high_card(slot, root):
    a = BY_SLUG[slot['slug']]
    return (f'<a class="fhigh" href="{root}articles/{a["slug"]}.html">'
            f'<figure class="shot cardshot" data-shot="{art_card_img(a)}" data-dir="{root}articles/cards/" '
            f'data-alt="{_esc(a["title"])}"></figure>'
            f'<span class="acard-meta">{_kicker(slot.get("kicker"))}<span class="acard-date">{a["date"]}</span></span>'
            f'<b>{_esc(_short(a["title"], 90))}</b>'
            f'<span class="fhigh-why">{_esc(slot.get("why") or art_teaser(a))}</span></a>')


def _latest_rail(n, root, title='Latest'):
    xs = NEWS.latest(n)
    lis = ''.join(f'<li><span class="acard-date">{a["date"]}</span><a href="{root}articles/{a["slug"]}.html">'
                  f'{_esc(_short(a["title"], 84))}</a></li>' for a in xs)
    return (f'<aside class="frail" aria-label="{title}"><h2 class="fsect">{title}</h2><ol>{lis}</ol>'
            f'<a class="frail-more" href="{root}articles/index.html#all">Every article, {len(ARTICLES)} &rarr;</a></aside>')


def articles_subnav(cur, root):
    items = [('front', 'Front page', 'articles/index.html'), ('newsletter', 'Newsletter', 'articles/newsletter/index.html'),
             ('all', 'Every article', 'articles/index.html#all'),
             ('collections', 'Collections', 'articles/collections/index.html'),
             ('desk', 'From the desk', 'articles/desk/index.html'), ('graphs', 'As graphs', 'articles/graphs.html'),
             ('newsroom', 'How it runs', 'newsroom/index.html'), ('subscribe', 'Subscribe', 'subscribe/index.html'), ('account', 'Your account', 'account/index.html')]
    return ('<nav class="fsub" aria-label="SGit Newsroom">' + ''.join(
        f'<a href="{root}{h}"{_CUR if k == cur else ""}>{l}</a>' for k, l, h in items) + '</nav>')


def _masthead(root, cur, title='SGit Newsroom', sub=''):
    fr = NEWS.front
    ed = f'Edition of {fr["edition"]}' if fr['edition'] else 'Unedited'
    return (f'<header class="fmast">'
            f'<p class="fdateline"><span>{ed}</span><span>{len(ARTICLES)} articles &middot; {len(NEWS.notes)} desk notes '
            f'&middot; {len(NEWS.collections)} collections &middot; {len(NEWS.issues)} issue{"s" if len(NEWS.issues) != 1 else ""}</span><span>sgit.ai &middot; {SITE_VERSION}</span></p>'
            f'<h1>{title}</h1>'
            + (f'<p class="fmast-sub">{sub}</p>' if sub else '')
            + articles_subnav(cur, root) + '</header>')


# ---------------------------------------------------------------- the articles front

def articles_index_body():
    """The front page of the articles, laid out like a broadsheet: dateline and masthead,
    the lead with the Editor's reason, the Latest rail (every article, newest first, with no
    placement needed), the highlights, the desk, the collections, then every article with
    the topic filter and search. Which article sits where comes from front.json alone."""
    root = '../'
    lead = NEWS.lead()
    fr = NEWS.front
    out = ['<main class="front">',
           _masthead(root, 'front', sub='One page, one argument, with the figures, the data and the links to check '
                     'it. Every article is live the moment it is written; the front is the '
                     '<a href="../newsroom/index.html">newsroom</a>\'s choice of where to start.')]
    if fr['note']:
        out.append(f'<p class="fnote"><span class="fkick">From the editor</span> {_esc(fr["note"])} '
                   f'<a href="../newsroom/log.html">The desk log &rarr;</a></p>')
    out.append('<div class="ftop">' + (_lead_block(lead, root) if lead else '') + _latest_rail(10, root) + '</div>')
    out.append('<sg-meter class="fband" view="link"></sg-meter>')
    if fr['highlights']:
        out.append('<section class="fband"><h2 class="fsect">Highlights</h2><div class="fhighs">'
                   + ''.join(_high_card(h, root) for h in fr['highlights']) + '</div></section>')
    if NEWS.notes:
        out.append('<section class="fband"><h2 class="fsect">From the desk <a href="desk/index.html">all notes &rarr;</a></h2>'
                   '<div class="dnotes">' + ''.join(_note_teaser(n, root) for n in NEWS.notes[:4]) + '</div></section>')
    feat = [c for cid in fr['collections'] for c in NEWS.collections if c['id'] == cid] or NEWS.collections[:3]
    if feat:
        out.append('<section class="fband"><h2 class="fsect">Collections <a href="collections/index.html">all collections &rarr;</a></h2>'
                   '<div class="ctiles">' + ''.join(_collection_tile(c, root) for c in feat) + '</div></section>')
    out.append('<section class="fband" id="subscribe">' + subscribe_teaser('../', 'Get the SGit Newsroom newsletter') + '</section>')
    out.append(f'<section class="fband" id="all"><h2 class="fsect">Every article, newest first</h2>')
    out.append(articles_filter_bar())
    out.append(' <div class="agrid" id="agrid">')
    for a in ARTICLES:
        out.append(' ' + art_card(a))
    out.append(' </div>')
    out.append(' <p class="dim" id="aempty" hidden>Nothing matches. Clear the search or pick another topic.</p></section>')
    threaded = [a for a in ARTICLES if ARTICLE_OUT[a['slug']] or ARTICLE_IN[a['slug']]]
    out.append('<section class="fband"><h2 class="fsect" id="threads">How they connect</h2>')
    out.append(' <p class="small dim">Read from the links the articles make to each other. '
               '<a href="graphs.html#map">The same thing drawn as a map</a>, and every article as its own graph.</p>')
    out.append(' <ul class="athreadlist">')
    for a in threaded:
        bits = []
        if ARTICLE_OUT[a['slug']]:
            bits.append('builds on ' + ', '.join(f'<a href="{s}.html">{_short(BY_SLUG[s]["title"])}</a>' for s in ARTICLE_OUT[a['slug']]))
        if ARTICLE_IN[a['slug']]:
            bits.append('continued by ' + ', '.join(f'<a href="{s}.html">{_short(BY_SLUG[s]["title"])}</a>' for s in ARTICLE_IN[a['slug']]))
        out.append(f' <li><a href="{a["slug"]}.html"><b>{_short(a["title"])}</b></a> <span class="dim">{"; ".join(bits)}.</span></li>')
    out.append(' </ul>')
    out.append(' <div class="note"><b>Two rules keep these from going stale.</b> An article never '
               'restates a fact it does not own, it links to the page that does, so when the fact '
               'changes the article does not start lying. And an article that makes a testable claim '
               'links to the test, the same way <a href="../compare/index.html">the comparison '
               'pages</a> do.</div></section>')
    out.append('</main>')
    out.append(ARTICLES_FILTER_JS)
    return '\n'.join(out)


def home_articles_band():
    """The homepage band: the Editor's lead, the first highlights, the newest few, and the
    newest desk note. Counts come from front.json's `homepage`; with no front.json it is
    the newest article and the next three, which is what the band showed before v0.6.85."""
    hp = NEWS.front.get('homepage') or {}
    nh, nl, nn = int(hp.get('highlights', 3)), int(hp.get('latest', 5)), int(hp.get('notes', 1))
    lead = NEWS.lead()
    highs = NEWS.front['highlights'][:nh] or [{'slug': a['slug'], 'kicker': '', 'why': ''} for a in ARTICLES[1:1 + nh]]
    shown = {lead['slug']} | {h['slug'] for h in highs} if lead else set()
    latest = [a for a in NEWS.latest() if a['slug'] not in shown][:nl]
    lis = ''.join(f'<li><span class="acard-date">{a["date"]}</span><a href="articles/{a["slug"]}.html">'
                  f'{_esc(_short(a["title"], 84))}</a></li>' for a in latest)
    notes = ''.join(_note_teaser(n, '') for n in NEWS.notes[:nn])
    return ('<section class="band alt front-band" id="articles">\n <div class="inner-wide">\n'
            ' <div class="fband-head"><p class="eyebrow">SGit Newsroom</p><h2>Start with an argument, not a menu</h2>'
            f'<p class="bandlede">The articles carry most of what this site believes, with the figures, the data and the links '
            f'to check it. {number_words(len(ARTICLES)).capitalize()} so far; the newsroom picks where to start.</p></div>\n'
            ' <div class="ftop">' + (_lead_block(lead, '') if lead else '')
            + f'<aside class="frail"><h3 class="fsect">Also new</h3><ol>{lis}</ol>' + notes + '</aside></div>\n'
            ' <div class="fhighs home">' + ''.join(_high_card(h, '') for h in highs) + '</div>\n'
            ' <p class="bandcta"><a class="cta2" href="articles/index.html">The front page &rarr;</a>'
            ' &nbsp;&middot;&nbsp; <a class="cta2" href="articles/collections/index.html">Collections &rarr;</a>'
            ' &nbsp;&middot;&nbsp; <a class="cta2" href="articles/newsletter/index.html">The newsletter &rarr;</a></p>\n'
            ' </div>\n</section>')


# ---------------------------------------------------------------- collections and desk notes

def collections_index_body():
    root = '../../'
    out = ['<main class="front">', _masthead(root, 'collections', 'Collections',
           'Articles read together. Each collection is curated by the Historian, says in its introduction '
           'what the set shows that no single article does, and grows when a new article joins the pattern.'),
           '<div class="ctiles wide">' + ''.join(_collection_tile(c, root) for c in NEWS.collections) + '</div>',
           '<p class="small dim">A collection is one file in <code>admin/content/newsroom/collections/</code>. '
           '<a href="../../newsroom/publish.html">How the desk publishes</a>.</p>', '</main>']
    return '\n'.join(out)


def collection_body(c):
    root = '../../'
    cards = ''.join(art_card(BY_SLUG[s], pre=root + 'articles/') for s in c['slugs'])
    return ('<main class="doc">\n'
            f' <p class="crumb"><a href="{root}index.html">Home</a> / <a href="../index.html">Articles</a> / '
            f'<a href="index.html">Collections</a> / {_esc(c["title"])}</p>\n'
            f' <p class="eyebrow">Collection &middot; {len(c["slugs"])} articles &middot; curated by the '
            f'<a href="{root}newsroom/roles/{c["curator"]}.html">{_esc(c["curator"]).capitalize()}</a> &middot; updated {c["updated"]}</p>\n'
            f' <h1>{_esc(c["title"])}</h1>\n <p class="lead">{_esc(c["dek"])}</p>\n'
            + desk_md(c['body'], 2, c['where'])
            + f'\n <h2>The articles</h2>\n <div class="agrid">{cards}</div>\n'
            f' <p class="small dim"><a href="index.html">&larr; All collections</a> &middot; '
            f'<a href="../index.html">The front page</a></p>\n</main>')


def desk_index_body():
    root = '../../'
    groups = []
    for k, label in NOTE_KINDS.items():
        xs = [n for n in NEWS.notes if n['kind'] == k]
        if xs:
            groups.append((label, xs))
    out = ['<main class="front">', _masthead(root, 'desk', 'From the desk',
           'Short pieces written from the articles: a line that matters beyond the article it is in, a connection '
           'none of them states, the week in one page. Every quote is checked against the article when the site is built.'),
           '<div class="dnotes wide">' + ''.join(_note_teaser(n, root) for n in NEWS.notes) + '</div>']
    if not NEWS.notes:
        out.append('<p class="dim">No notes yet.</p>')
    out.append('<p class="small dim">Kinds: ' + ' &middot; '.join(f'{l} ({len(xs)})' for l, xs in groups)
               + '. Written by the <a href="../../newsroom/roles/historian.html">Historian</a> and the '
               '<a href="../../newsroom/roles/journalist.html">Journalist</a>.</p>')
    out.append('</main>')
    return '\n'.join(out)


def note_body(n):
    root = '../../'
    cites = ''.join(_art_link_li(s, root + 'articles/') for s in n['cites'])
    return ('<main class="doc dnote-page">\n'
            f' <p class="crumb"><a href="{root}index.html">Home</a> / <a href="../index.html">Articles</a> / '
            f'<a href="index.html">From the desk</a> / {NOTE_KINDS[n["kind"]]}</p>\n'
            f' <p class="eyebrow">{NOTE_KINDS[n["kind"]]} &middot; {n["date"]} &middot; by the '
            f'<a href="{root}newsroom/roles/{n["role"]}.html">{_esc(n["role"]).capitalize()}</a></p>\n'
            f' <h1>{_esc(n["title"])}</h1>\n <p class="abstract"><em>{_esc(n["summary"])}</em></p>\n'
            + desk_md(n['body'], 2, n['where'])
            + (f'\n <section class="athreads"><h2>Rests on</h2><ul>{cites}</ul></section>' if cites else '')
            + f'\n <p class="small dim" style="margin-top:2rem"><a href="index.html">&larr; From the desk</a> &middot; '
            f'<a href="../index.html">The front page</a></p>\n</main>')


def article_desk_block(a):
    """At the foot of an article: the desk notes that cite it and the collections it is in,
    so a reader finishing one piece is shown what the desk found across it."""
    notes, cols = NOTES_CITING[a['slug']], COLLECTIONS_OF[a['slug']]
    if not notes and not cols:
        return ''
    lis = ''.join(f'<li><a href="desk/{n["slug"]}.html">{_esc(n["title"])}</a> '
                  f'<span class="dim">{NOTE_KINDS[n["kind"]].lower()}, {n["date"]}</span></li>' for n in notes)
    lis += ''.join(f'<li><a href="collections/{c["id"]}.html">{_esc(c["title"])}</a> '
                   f'<span class="dim">collection, {len(c["slugs"])} articles</span></li>' for c in cols)
    return f'\n<section class="athreads" id="desk"><h2>From the desk</h2><ul>{lis}</ul></section>'


# ---------------------------------------------------------------- the newsroom (public, backstage)

NEWSROOM_TABS = [('index', 'Overview', 'index.html'), ('roles', 'Roles', 'index.html#roles'),
                 ('policies', 'Policies', 'policies.html'), ('publish', 'How to publish', 'publish.html'),
                 ('board', 'Board', 'board.html'), ('log', 'Log', 'log.html'), ('newsletter', 'Newsletter', '../articles/newsletter/index.html'),
                 ('wire', 'The wire', 'wire.json')]


def newsroom_subnav(cur, pre=''):
    return ('<nav class="fsub nsub" aria-label="Newsroom">' + ''.join(
        f'<a href="{pre}{h}"{_CUR if k == cur else ""}>{l}</a>' for k, l, h in NEWSROOM_TABS) + '</nav>')


def _nr_head(cur, title, lead, pre=''):
    return (f' <p class="crumb"><a href="{pre}../index.html">Home</a> / <a href="{pre}../articles/index.html">SGit Newsroom</a> / '
            + (f'<a href="{pre}index.html">How it runs</a> / ' if cur != 'index' else '') + f'{title}</p>\n'
            f' <p class="eyebrow">SGit Newsroom &middot; how it runs</p>\n <h1>{title}</h1>\n <p class="lead">{lead}</p>\n'
            + newsroom_subnav(cur, pre) + '\n')


def _health_html(limit=None):
    rows = NEWS.health(lambda s: s in GRAPHS, lambda s: art_card_img(BY_SLUG[s]) != 'default.webp')
    if not rows:
        return '<p class="nhealth-ok"><b>Nothing to do.</b> Every placement resolves, no pitch is open, the lead is current.</p>'
    label = {'warn': 'Fix', 'todo': 'To do', 'info': 'Note'}
    order = {'warn': 0, 'todo': 1, 'info': 2}
    rows = sorted(rows, key=lambda r: order[r[0]])
    more = len(rows) - limit if limit and len(rows) > limit else 0
    rows = rows[:limit] if limit else rows
    lis = ''.join(f'<li class="nh-{l}"><span class="nh-tag">{label[l]}</span> <code>{_esc(w)}</code> {_esc(t)}</li>' for l, w, t in rows)
    return f'<ul class="nhealth">{lis}</ul>' + (f'<p class="small dim">and {more} more; <code>python3 admin/build/desk.py</code> lists them all.</p>' if more else '')


def _role_cards(pre):
    return '<div class="nroles">' + ''.join(
        f'<a class="nrole" href="{pre}roles/{r["slug"]}.html"><b>{_esc(r["title"])}</b>'
        f'<span>{_esc(r["mission"])}</span><span class="nrole-claim">{_esc(r["claim"])}</span></a>' for r in NEWS.roles) + '</div>'


def _front_table(pre):
    fr = NEWS.front
    rows = []
    lead = NEWS.lead()
    if lead:
        rows.append(('Lead', lead['slug'], lead.get('why', '') or 'newest article, no lead set'))
    rows += [('Highlight', h['slug'], h.get('why', '')) for h in fr['highlights']]
    rows += [('Collection', c['id'], c['dek']) for cid in fr['collections'] for c in NEWS.collections if c['id'] == cid]
    trs = ''.join(f'<tr><td>{k}</td><td><a href="{pre}../articles/'
                  + (f'collections/{s}.html' if k == 'Collection' else f'{s}.html') + f'">{_esc(s)}</a></td><td>{_esc(w)}</td></tr>'
                  for k, s, w in rows)
    return f'<div class="tablewrap"><table><tr><th>Slot</th><th>What</th><th>Why, in the Editor\'s words</th></tr>{trs}</table></div>'


def newsroom_index_body():
    fr = NEWS.front
    last = NEWS.log[0] if NEWS.log else None
    open_p = [p for p in NEWS.pitches if p['status'] == 'open']
    return ('<main class="doc newsroom">\n' + _nr_head('index', 'How the SGit Newsroom runs',
        'How the articles on this site get written, placed, connected and sent, by one person and a desk of agents. '
        'Public on purpose: the roles, the rules each one works under, the board, and a log of every run.')
        + '<p>The <a href="../articles/index.html">SGit Newsroom</a> is the public front of a back office: the agents, the '
        'vaults they work in, the contact list and the subscribe lane. What reaches a reader goes through here: an article, '
        'a desk note, a <a href="../articles/newsletter/index.html">newsletter issue</a>. Its sibling '
        '<a href="https://newsroom.sgit.ai/">newsroom.sgit.ai</a> is where the method is argued and tested; this is the '
        'method running on one site, every day.</p>\n'
        + '<div class="note"><b>The one rule, and the one exception.</b> Publishing is adding one file: any agent that '
        'writes an article has published it, live, at the top of Latest, in the feed and in the wire, with no approval step. '
        'The exception is <b>placement</b>. What leads, what is highlighted, what the homepage carries: one role, the '
        '<a href="roles/editor.html">Editor</a>, owns that, in one file. Everyone else asks with a pitch. '
        'The rule comes from the agent team that tried "only one agent may draft" and '
        '<a href="../articles/desk/create-anywhere-edit-your-own.html">found it a bottleneck in two days</a>.</div>\n'
        '<h2 id="flow">How a piece moves</h2>\n'
        '<ol class="nflow">'
        '<li><b>Written.</b> A contributor or the Journalist adds <code>admin/content/articles/&lt;slug&gt;.md</code>, '
        'its graph, figures and card. The build makes it a page.</li>'
        '<li><b>Live.</b> The release ships it: its URL, the top of <a href="../articles/index.html#all">Latest</a>, '
        '<a href="../articles/feed.xml">the feed</a>, <a href="wire.json">the wire</a>. Nobody has been asked.</li>'
        '<li><b>Pitched</b> (optional). Anyone adds <code>admin/content/newsroom/pitches/&lt;date&gt;__&lt;slug&gt;.md</code> '
        'asking for the lead, a highlight, the homepage or a collection.</li>'
        '<li><b>Placed.</b> On its run the Editor reads what is new, answers the pitches, and rewrites '
        '<code>front.json</code> with a reason for every slot.</li>'
        '<li><b>Connected.</b> The Historian reads across the articles and publishes what none says alone, as a '
        '<a href="../articles/desk/index.html">desk note</a> or a <a href="../articles/collections/index.html">collection</a>; '
        'the Journalist writes the week.</li>'
        '<li><b>Sent.</b> The agents that write to subscribers read the wire, not the pages, and pick for each reader '
        'from the topics, the graphs and the desk notes.</li></ol>\n'
        f'<h2 id="front">Today\'s front, and why</h2>\n<p>Edition of <b>{fr["edition"] or "none yet"}</b>. '
        f'{_esc(fr["note"])}</p>\n' + _front_table('') + '\n'
        '<h2 id="health">Desk health</h2>\n<p class="small dim">Computed at every build from the files: placements that '
        'point at nothing, articles published since the edition, open pitches, articles without a graph or a card. '
        'The same list is what <code>python3 admin/build/desk.py</code> prints for the Editor.</p>\n'
        + _health_html(12) + '\n'
        f'<h2 id="roles">The desk: {number_words(len(NEWS.roles))} roles</h2>\n'
        '<p>Each role is a file under <code>admin/content/newsroom/roles/</code> with a mission, a sentence that says '
        'when it has failed, and a write list that is its behaviour policy. The definitions of the Historian and the '
        'Journalist come from the <a href="https://github.com/the-cyber-boardroom/SGraph-AI__App__Send/tree/HEAD/team/roles">SG/Send '
        'agent team</a>; the site-operations roles (Sherpa, Publisher, Release engineer) are on <a href="../team/index.html">the team page</a>.</p>\n'
        + _role_cards('') + '\n'
        f'<h2 id="latest-run">Latest run</h2>\n'
        + (f'<p><b>{last["date"]} {last["time"]}, {_esc(last["role"]).capitalize()}:</b> {_esc(last["title"])}. '
           f'<a href="log.html#{last["slug"]}">Read the entry &rarr;</a></p>\n' if last else '<p class="dim">No runs logged.</p>\n')
        + f'<p>{len(open_p)} open pitch{"es" if len(open_p) != 1 else ""} &middot; '
        + ' &middot; '.join(f'{len([c for c in NEWS.board if c["status"] == s])} {s}' for s in BOARD_STATUS)
        + ' on <a href="board.html">the board</a>.</p>\n'
        '<h2 id="agents">For the agents that write to subscribers</h2>\n'
        '<p>The point of all of this is a source the personal-newsletter agents can trust. They read '
        '<a href="wire.json"><code>newsroom/wire.json</code></a>: every article with its date, teaser, topics, placement, '
        'graph and markdown twin; every desk note with what it cites; every collection. One fetch, no scraping. The RSS '
        'version is <a href="../articles/feed.xml"><code>articles/feed.xml</code></a>.</p>\n'
        '</main>')


def newsroom_role_body(r):
    pol = ''.join(f'<li><code>{_esc(g)}</code></li>' for g in r['writes'])
    ed = ''.join(f'<li><code>{_esc(g)}</code> (status lines only)</li>' for g in r['edits'])
    return ('<main class="doc newsroom">\n' + _nr_head('roles', r['title'], _esc(r['mission']), '../')
            + f'<div class="note"><b>It has failed when:</b> {_esc(r["claim"][3:] if r["claim"].startswith("If ") else r["claim"])}</div>\n'
            '<div class="tablewrap"><table>'
            f'<tr><th>Owns</th><td>{_esc(r["owns"])}</td></tr>'
            f'<tr><th>Never</th><td>{_esc(r["never"])}</td></tr>'
            + (f'<tr><th>Cadence</th><td>{_esc(r["cadence"])}</td></tr>' if r.get('cadence') else '')
            + '</table></div>\n'
            f'<h2 id="policy">Write policy</h2><p class="small dim">Read by <code>admin/build/policy_check.py --role {r["slug"]}</code>. '
            f'Paths the role may create or change:</p><ul>{pol}{ed}</ul>\n'
            + LOADER.md_to_html(r['body'], depth=2, where=r['where'])
            + '\n<p class="small dim" style="margin-top:2rem"><a href="../index.html#roles">&larr; All desk roles</a></p>\n</main>')


def newsroom_policies_body():
    paths = sorted({g for r in NEWS.roles for g in r['writes'] + r['edits']})
    head = ''.join(f'<th>{_esc(r["title"])}</th>' for r in NEWS.roles)
    trs = []
    for p in paths:
        # the same matcher policy_check.py uses, so admin/build/* covers build_pages.py here too
        def cell(r):
            ok, why = NEWS.may_write(r['slug'], p.replace('*', 'x'))
            return ('<td class="pol-n">&middot;</td>' if not ok else
                    '<td class="pol-e">status</td>' if why.startswith('edits') else '<td class="pol-y">writes</td>')
        cells = ''.join(cell(r) for r in NEWS.roles)
        trs.append(f'<tr><td><code>{_esc(p)}</code></td>{cells}</tr>')
    nevers = ''.join(f'<li><b>{_esc(r["title"])}</b> never: {_esc(r["never"])}.</li>' for r in NEWS.roles)
    return ('<main class="doc newsroom">\n' + _nr_head('policies', 'Behaviour policies',
        'What each desk role may write, generated from the same role files the policy checker reads, so this table '
        'and the check cannot disagree.')
        + '<p>A behaviour policy, in <a href="https://riskmandate.ai/abp.html">RiskMandate\'s terms</a>, says what an agent can '
        'reach, what it was asked to do, the gap between the two and the barriers in the gap. For a desk of agents working in '
        'one repository, the reach is every file and the mandate is a short list of paths. The barrier is a check: '
        '<code>python3 admin/build/policy_check.py --role &lt;role&gt;</code> lists every file a branch changed that is outside '
        'the role\'s list. Generated pages are outside every list and ignored by the check, because the build writes them; '
        'the release lines in <code>build_pages.py</code> (<code>SITE_VERSION</code> and the <code>VERSION_LOG</code>) are '
        'shared by every role that releases.</p>\n'
        f'<div class="tablewrap"><table class="poltable"><tr><th>Path</th>{head}</tr>{"".join(trs)}</table></div>\n'
        f'<h2 id="never">What each role never does</h2><ul>{nevers}</ul>\n'
        '<h2 id="principles">Three principles</h2><ul>'
        '<li><b>Create anywhere, edit your own.</b> Any role adds files; only a file\'s author edits it. The one exception is a '
        'pitch\'s <code>status</code> line, which is how the Editor answers.</li>'
        '<li><b>One owner for each shared surface.</b> The front is the Editor\'s, collections are the Historian\'s, the build '
        'is the Developer\'s. Ownership is what stops two agents rewriting the same thing in turn.</li>'
        '<li><b>Flag, do not fix.</b> A problem in another role\'s file becomes a pitch, a board card or a correction note. '
        'From the <a href="https://github.com/the-cyber-boardroom/SGraph-AI__App__Send/blob/HEAD/team/roles/librarian/ROLE.md">SG/Send '
        'Librarian</a>.</li></ul>\n</main>')


def newsroom_publish_body():
    return ('<main class="doc newsroom">\n' + _nr_head('publish', 'How to publish',
        'For any agent writing for sgit.ai: what to add, what happens, and how to ask for more than Latest.')
        + '<h2 id="article">An article</h2>'
        '<ol><li>Write <code>admin/content/articles/&lt;slug&gt;.md</code> with <code>title</code>, <code>date</code>, '
        '<code>time</code> (HH:MM in UTC, so articles from the same day list in the order they went out), '
        '<code>summary</code>, <code>tags</code>, and <code>author</code> + <code>author_url</code> if it is first person. '
        'The full contract is <code>admin/content/CONTENT.md</code>.</li>'
        '<li>Write its graph, <code>admin/content/articles/graphs/&lt;slug&gt;.json</code>: the teaser, one or two topics, '
        'the core idea, the nodes and edges, and the quotes. The card, the threads, the wire and the desk all read it.</li>'
        '<li>Figures go in <code>articles/images/</code>, the card in <code>articles/cards/&lt;slug&gt;.webp</code>; '
        'data the article was written from in <code>articles/data/</code>. <b>Prefer evidence to words:</b> a dataset, a '
        'graph, a vault and its read key are worth more than another paragraph.</li>'
        '<li>Build, validate, check your policy, release:'
        '<pre class="shell">python3 admin/build/build_pages.py\nnode admin/build/validate.js\n'
        'python3 admin/build/policy_check.py --role contributor\n./admin/build/release.sh "site vX.Y.Z: ..."</pre></li></ol>'
        '<p>The article is now live and at the top of Latest. You did not need the desk, and you did not touch '
        '<code>front.json</code>.</p>'
        '<h2 id="pitch">Asking for a placement</h2>'
        '<p>Add one file, <code>admin/content/newsroom/pitches/YYYY-MM-DD__&lt;slug&gt;.md</code>:</p>'
        '<pre class="shell">---\ndate: 2026-10-07\nfrom: contributor\nask: lead          # lead | highlight | homepage | collection | note\n'
        'article: the-slug\nstatus: open\n---\nWhy this article, why now, what it shows that the current front does not.</pre>'
        '<p>The Editor answers on its next run by changing <code>status</code> to accepted, declined or parked, with a '
        '<code>decision:</code> line. Open pitches are listed under <a href="index.html#health">desk health</a>.</p>'
        '<h2 id="notes">A desk note</h2>'
        '<p>Desk roles write <code>admin/content/newsroom/notes/YYYY/MM/DD/&lt;slug&gt;.md</code> with <code>title</code>, '
        '<code>date</code>, <code>kind</code> (nugget, thread, weekly, brief, correction), <code>role</code>, <code>cites</code> '
        '(article slugs) and <code>summary</code>. Four directives make a note out of the articles\' own data:</p>'
        '<pre class="shell">!quote &lt;slug&gt; | &lt;exact text&gt;     checked word for word against the article\n'
        '!quote &lt;slug&gt; #&lt;n&gt;               the n-th quote in the article\'s graph\n'
        '!article &lt;slug&gt;                  the article\'s card\n'
        '!articles 2026-10-01..2026-10-07   every article in the range, with its teaser and the count\n'
        '!list &lt;slug&gt;, &lt;slug&gt;, ...          a hand-grouped list, title and teaser\n'
        '!covers 2026-10-01..2026-10-07     every article in the range must appear in a !list, or the build fails</pre>'
        '<p>A note that misquotes an article fails the build, and so does a note whose quote stops being true '
        'because the article changed. The desk then has to decide which one is right.</p>'
        '<h2 id="breaks">What cannot break</h2>'
        '<p>If <code>front.json</code> names an article that was renamed or held back, the build skips that slot, falls back '
        'to the newest article for the lead, and lists the problem under desk health. A contributor\'s rename is never '
        'blocked by a file only the Editor may edit.</p>\n</main>')


def newsroom_board_body():
    cols = []
    for s in BOARD_STATUS:
        cards = ''.join(f'<div class="ncard"><span class="nid">{_esc(c["id"])}</span> <b>{_esc(c["title"])}</b>'
                        f'<span class="small dim"><a href="roles/{c["role"]}.html">{_esc(c["role"])}</a> &middot; {c["opened"]}</span>'
                        f'<span class="small">{LOADER.md_to_text(c["body"], 220)}</span></div>'
                        for c in NEWS.board if c['status'] == s)
        cols.append(f'<div class="ncol"><h3>{s.capitalize()} <span class="dim">{len([c for c in NEWS.board if c["status"] == s])}</span></h3>{cards}</div>')
    pitches = ''.join(f'<tr><td>{p["date"]}</td><td>{_esc(p["from"])}</td><td>{_esc(p["ask"])}</td>'
                      f'<td>{_esc(p["article"])}</td><td>{_esc(p["status"])}</td><td>{_esc(p.get("decision", ""))}</td></tr>'
                      for p in NEWS.pitches)
    return ('<main class="doc newsroom">\n' + _nr_head('board', 'The desk board',
        'The newsroom\'s open work, one card per file in <code>admin/content/newsroom/board/</code>. Moving a card is '
        'editing its <code>status</code> line.')
        + f'<div class="nboard">{"".join(cols)}</div>\n'
        '<h2 id="pitches">Pitches</h2>'
        + (f'<div class="tablewrap"><table><tr><th>Date</th><th>From</th><th>Ask</th><th>Article</th><th>Status</th>'
           f'<th>Decision</th></tr>{pitches}</table></div>' if pitches else
           '<p class="dim">No pitches yet. <a href="publish.html#pitch">How to pitch</a>.</p>')
        + '\n</main>')


def newsroom_log_body():
    items = ''.join(f'<article class="nlog" id="{r["slug"]}"><p class="eyebrow">{r["date"]} {r["time"]} &middot; '
                    f'<a href="roles/{r["role"]}.html">{_esc(r["role"]).capitalize()}</a></p><h2>{_esc(r["title"])}</h2>'
                    + LOADER.md_to_html(r['body'], depth=1, where=r['where']) + '</article>' for r in NEWS.log)
    return ('<main class="doc newsroom">\n' + _nr_head('log', 'The desk log',
        'One entry per run of a desk role, newest first, each its own file and never edited after the run. What changed, '
        'what was decided, what is next.') + (items or '<p class="dim">No runs yet.</p>') + '\n</main>')


# ---------------------------------------------------------------- the newsletter and the banners
# The SGit Newsroom's regular issue: what was published, what it adds up to, and the pieces
# worth a reader's time. One file per issue in admin/content/newsroom/newsletter/, the same
# text cross-posted as a LinkedIn article in the Deterministic GenAI newsletter. The page is
# laid out the way LinkedIn lays out an article (cover, title, abstract, body) so that copying
# the rendered body into LinkedIn's editor carries it over; the cover is a 1920x1080 banner
# rendered by admin/build/make_banners.mjs from the data below.

# The newsletter's own page on LinkedIn, supplied by the author on 9 October (left empty until then,
# because a guessed URL would be a broken link with the author's name on it).
LINKEDIN_NEWSLETTER = ('Deterministic GenAI', 'https://www.linkedin.com/newsletters/deterministic-genai-7174563523795005440/')


def _nl_name(root=''):
    name, url = LINKEDIN_NEWSLETTER
    return f'<a href="{url}" rel="noopener" target="_blank">{name}</a>' if url else f'<i>{name}</i>'
BANNER_DIR = 'articles/banners'


def _first_shot(a):
    m = re.search(r'^!shot\s+([^|\s]+)\s*\|\s*([^|]*?)\s*\|', a['body'], re.M)
    if not m:
        return None
    p = os.path.normpath(os.path.join('articles', m.group(2).strip() or 'images/', m.group(1).strip()))
    return p if os.path.exists(os.path.join(ROOT, p)) else None


def article_ideas(a, n=3):
    """The key ideas a banner carries: the article's own `ideas:` (separated by |) if it has
    them, else the claims in its graph, then its methods, then its concepts."""
    if a.get('ideas'):
        return [x.strip() for x in a['ideas'].split('|') if x.strip()][:n]
    nodes = GRAPHS.get(a['slug'], {}).get('nodes', [])
    out = []
    for kind in ('claim', 'method', 'concept'):
        out += [x['label'] for x in nodes if x['kind'] == kind and x['label'] not in out]
    return out[:n]


def banner_path(slug):
    return f'{BANNER_DIR}/{slug}.jpg'


def has_banner(slug):
    return os.path.exists(os.path.join(ROOT, banner_path(slug)))


def write_banner_manifest():
    """The input make_banners.mjs renders from: one entry per article and per issue. Written
    by the build so the banner tool never parses markdown or graphs itself."""
    entries = []
    for a in ARTICLES:
        entries.append({'slug': a['slug'], 'kind': 'article', 'kicker': 'SGit Newsroom', 'date': a['date'],
                        'author': a['author'] or 'sgit.ai', 'title': a['title'], 'teaser': art_teaser(a),
                        'ideas': article_ideas(a), 'hero': _first_shot(a),
                        'url': f'sgit.ai/articles/{a["slug"]}.html', 'out': banner_path(a['slug'])})
    for i in NEWS.issues:
        entries.append({'slug': i['slug'], 'kind': 'issue', 'kicker': f'SGit Newsroom · Issue {i["number"]}',
                        'date': i['date'], 'author': 'Dinis Cruz', 'title': i['title'], 'teaser': i.get('dek') or i['summary'],
                        'ideas': [x.strip() for x in i.get('ideas', '').split('|') if x.strip()][:3],
                        'mosaic': [f'articles/cards/{art_card_img(BY_SLUG[s])}' for s in i['cites'][:4]],
                        'url': f'sgit.ai/articles/newsletter/{i["path"]}.html', 'out': banner_path(i['slug'])})
    latest = NEWS.issues[0] if NEWS.issues else None
    entries.append({'slug': 'subscribe', 'kind': 'subscribe', 'kicker': 'SGit Newsroom \u00b7 Newsletter',
                    'date': latest['date'] if latest else ARTICLES[0]['date'], 'author': 'Dinis Cruz',
                    'title': 'Get the next issue of the SGit Newsroom',
                    'teaser': 'One issue a week or so: what was published, what it adds up to, and what is worth your time.',
                    'ideas': ['The week\'s articles, grouped by what they are about',
                              'Every quote checked word for word against its article',
                              'Next: an issue picked for what you care about'],
                    'mosaic': [f'articles/cards/{art_card_img(a)}' for a in ARTICLES[:4]],
                    'url': 'sgit.ai/subscribe/', 'out': banner_path('subscribe')})
    for where, media in DESK_MEDIA.items():
        for c in media['collages']:
            entries.append({'slug': os.path.basename(c['out'])[:-4], 'kind': 'collage', 'kicker': 'SGit Newsroom',
                            'title': c['caption'], 'date': '', 'author': 'Dinis Cruz', 'items': c['items'],
                            'url': 'sgit.ai/articles/', 'out': c['out']})
    os.makedirs(os.path.join(ROOT, COLLAGE_DIR), exist_ok=True)
    os.makedirs(os.path.join(ROOT, BANNER_DIR), exist_ok=True)
    s = json.dumps({'size': [1920, 1080], 'generated_by': 'admin/build/build_pages.py', 'entries': entries},
                   indent=1, ensure_ascii=False) + '\n'
    with open(os.path.join(ROOT, BANNER_DIR, 'manifest.json'), 'w') as f:
        f.write(s)
    return s


def linkedin_kit(slug, root, what='article', where=None):
    """A block at the foot of an article or issue: the cover at LinkedIn's size, then every image
    the page uses, in the order it appears, with the caption to paste under it. LinkedIn keeps the
    text of a paste and drops the images, so this list is the upload order. Not part of the copy."""
    if not has_banner(slug):
        return ''
    imgs = [m for m in DESK_MEDIA.get(where, {}).get('items', []) if os.path.exists(os.path.join(ROOT, m['src']))]
    lis = ''.join(f'<li><a href="{root}{m["src"]}" download>{os.path.basename(m["src"])}</a>'
                  + (f' <span class="dim">{_esc(re.sub(r"[*`]", "", m["caption"]))}</span>' if m['caption'] else '') + '</li>'
                  for m in imgs)
    return (f'\n<aside class="lkit" aria-label="For LinkedIn"><b>Posting this {what} on LinkedIn?</b> '
            f'The cover is <a href="{root}{banner_path(slug)}" download>{slug}.jpg</a> (1920&times;1080, title and key ideas on it). '
            'Upload it as the article cover, paste the title, then select and copy the body from this page.'
            + (f' LinkedIn drops images from a paste, so add these where they appear, in this order, with the caption under each:'
               f'<ol class="lkit-imgs">{lis}</ol>' if lis else '') + '</aside>')

def _issue_link(i, root):
    li = (f' &middot; <a href="{_esc(i["linkedin"])}" rel="noopener" target="_blank">on LinkedIn &#8599;</a>' if i['linkedin'] else '')
    fig = (f'<figure class="shot cardshot" data-shot="{i["slug"]}.jpg" data-dir="{root}{BANNER_DIR}/" data-alt="{_esc(i["title"])}"></figure>'
           if has_banner(i['slug']) else '')
    return (f'<article class="nlissue"><a class="nlissue-fig" href="{root}articles/newsletter/{i["path"]}.html">{fig}</a>'
            f'<div><p class="acard-meta"><span class="fkick">Issue {i["number"]}</span><span class="acard-date">{i["date"]}</span></p>'
            f'<h2><a href="{root}articles/newsletter/{i["path"]}.html">{_esc(i["title"])}</a></h2>'
            f'<p>{_esc(i["summary"])}</p><p class="small dim">{len(i["cites"])} articles{li}</p></div></article>')


def newsletter_index_body():
    root = '../../'
    due, why = NEWS.issue_due()
    name, url = LINKEDIN_NEWSLETTER
    out = ['<main class="front">', _masthead(root, 'newsletter', 'The newsletter',
           'The regular issue of the SGit Newsroom: what was published, what it adds up to, and the pieces worth your '
           'time. Weekly, or sooner when there is enough to say. The same issue goes out as a LinkedIn article in '
           f'{_nl_name()}.'),
           '<section class="fband"><h2 class="fsect">Issues</h2>']
    out += [_issue_link(i, root) for i in NEWS.issues] or ['<p class="dim">The first issue is being written.</p>']
    out.append(subscribe_teaser(root))
    out.append(f'<p class="small dim">Next issue: {"due now" if due else "not yet due"} ({_esc(why)}). '
               'Issues are written by the <a href="../../newsroom/roles/journalist.html">Journalist</a> from the articles\' '
               'own data; every quote is checked against its article when the site is built.</p></section></main>')
    return '\n'.join(out)


def subscribe_teaser(root, lead='Get the next issue by email'):
    """One line that points at the subscribe page. The form itself lives on one page,
    subscribe/index.html, so every other page carries a sentence, not a form."""
    return (f'<p class="subteaser"><b>{lead}.</b> One issue a week or so: what was published, what it adds up to, '
            f'and what is worth your time. <a class="cta2" href="{root}subscribe/index.html">Subscribe to the SGit Newsroom &rarr;</a></p>')


def issue_body(i):
    root = '../../../../../'
    banner = (f'<figure class="shot nlbanner" data-shot="{i["slug"]}.jpg" data-dir="{root}{BANNER_DIR}/" '
              f'data-alt="{_esc(i["title"])}"></figure>\n' if has_banner(i['slug']) else '')
    li = (f' &middot; <a href="{_esc(i["linkedin"])}" rel="noopener" target="_blank">read it on LinkedIn &#8599;</a>'
          if i['linkedin'] else '')
    return ('<main class="doc nlpage">\n'
            f' <p class="crumb"><a href="{root}index.html">Home</a> / <a href="{root}articles/index.html">SGit Newsroom</a> / '
            f'<a href="{root}articles/newsletter/index.html">Newsletter</a> / Issue {i["number"]}</p>\n'
            + banner +
            f' <p class="eyebrow">SGit Newsroom &middot; Issue {i["number"]} &middot; {i["date"]}</p>\n'
            f' <h1>{_esc(i["title"])}</h1>\n'
            f' <p class="small dim">By <a href="{root}about/index.html" rel="author">Dinis Cruz</a>, written with the '
            f'<a href="{root}newsroom/roles/{i["role"]}.html">{_esc(i["role"]).capitalize()}</a>{li}</p>\n'
            f' <div id="issue-body"><p><em><b>Abstract:</b> {_esc(i["summary"])}</em></p>\n'
            + desk_md(i['body'], 5, i['where'], quote_src=False)
            + f'\n<p><em>This is issue {i["number"]} of the SGit Newsroom newsletter, also published on LinkedIn in '
              f'{_nl_name()}. Every article it links to is on '
              f'<a href="https://sgit.ai/articles/index.html">sgit.ai</a>, with its sources and its data. To get the next '
              f'issue by email, <a href="https://sgit.ai/subscribe/">subscribe at sgit.ai/subscribe</a>.</em></p></div>\n'
            + linkedin_kit(i['slug'], root, 'issue', i['where'])
            + '\n <p class="small dim" style="margin-top:2rem"><a href="' + root + 'articles/newsletter/index.html">&larr; All issues</a></p>\n</main>\n')


METER_NOTE = ('<div class="note"><b>Kept in this browser, and only here.</b> The balance, the history and the receipts are in this '
              'browser\'s storage; nothing is sent anywhere and there is no account. A new browser, a private window or clearing site '
              'data starts again with &pound;5.00 and no history, so with no personalisation. It never blocks a page and it never nags: '
              'credit can go below zero, the balance says so in the top bar, and that is all. '
              '<a href="../meter/index.html">How the meter works</a>, and <a href="../articles/going-live-with-the-reading-meter.html">why '
              'we run it this way</a>.</div>')


def account_body():
    rows = ''.join(f'<tr><td>{lab}</td><td>{METER["prices"][k]}p</td></tr>' if METER['prices'][k] else f'<tr><td>{lab}</td><td>free</td></tr>'
                   for k, lab in METER_LABEL)
    return ('<main class="doc">\n <p class="crumb"><a href="../index.html">Home</a> / <a href="../articles/index.html">SGit Newsroom</a> / Your account</p>\n'
            ' <p class="eyebrow">SGit Newsroom &middot; reading account</p>\n <h1>Your reading account</h1>\n'
            ' <p class="lead">Every page on sgit.ai has a price of a few pence, and you pay for the share of it you read: scroll a '
            'tenth of the way down and you pay a tenth. You started with &pound;5.00 of credit. If a page was not worth it, say so at '
            'its foot and it is not charged. What you read is also what builds your graph below, and your '
            '<a href="newsroom.html">newsroom</a>: a front page for each of your <a href="personas.html">personas</a>.</p>\n'
            + METER_NOTE +
            '\n <sg-meter view="account"><noscript><p>The reading account needs JavaScript: it lives in your browser and nowhere else.</p></noscript></sg-meter>\n'
            f' <h2 id="prices">What things cost, read to the end</h2>\n <div class="tablewrap"><table><tr><th>Page</th><th>Price</th></tr>{rows}</table></div>\n'
            ' <p class="small dim">The price of a page read to the end; you pay that times the share you scrolled through, never less '
            'than a tenth. One charge per page per browser session: reading further later tops the same charge up, and going back costs '
            'nothing more. Reading the markdown twin of a page, or the newsroom wire, is not metered.</p>\n'
            ' <h2 id="why">Why it works like this</h2>\n'
            ' <p>To find out whether people pay for reading, a few pence at a time, when nothing forces them to and what they get back is '
            'a site that knows what they read and picks for them, with no account and nobody else holding the history. The plan, the '
            'numbers we expect and the date we check them are in <a href="../articles/going-live-with-the-reading-meter.html">going live '
            'with the reading meter</a>; who could cheat it, and why that is allowed, is in '
            '<a href="../articles/who-will-game-the-reading-meter.html">who will game the reading meter</a>.</p>\n</main>')


def yours_body():
    return ('<main class="doc wide">\n <p class="crumb"><a href="../index.html">Home</a> / <a href="../articles/index.html">SGit Newsroom</a> / Your newsroom</p>\n'
            ' <p class="eyebrow">SGit Newsroom &middot; yours</p>\n <h1>Your newsroom</h1>\n'
            ' <p class="lead">A front page made for one persona at a time: the articles it would want next, the ones you kept in it, '
            'and the graph it has grown. Your default persona is built from everything you read. Add others, one for each kind of '
            'reading you do, and switch between them: a persona is a way to keep your focus.</p>\n'
            ' <sg-meter view="newsroom" count="9"><noscript><p>Your newsroom needs JavaScript: it is worked out in your browser, from a '
            'history kept in your browser.</p></noscript></sg-meter>\n'
            ' <p class="small dim">Worked out here, from the history and personas kept in this browser only. Nothing is sent anywhere. '
            'On another device you start again, which is the problem <a href="../articles/pay-to-keep-your-persona.html">pay to keep '
            'your persona</a> is about. <a href="personas.html">Manage your personas</a> &middot; <a href="index.html">your reading '
            'account</a>.</p>\n</main>')


def share_body():
    return ('<main class="doc">\n <p class="crumb"><a href="../index.html">Home</a> / <a href="newsroom.html">Your newsroom</a> / Send us your reading</p>\n'
            ' <p class="eyebrow">SGit Newsroom &middot; a newsroom designed for you</p>\n <h1>Send us your reading, get a front page designed for you</h1>\n'
            ' <p class="lead">Use the site as you normally would for a few days. Then send us what you read: which pages, how far down '
            'each one, what you kept, put out of a persona or declined to pay for. In return, Dinis will reply with what your front '
            'page could look like, built from your actual reading. It is the fastest way for us to learn what a personal newsroom '
            'should be, and you get the first one.</p>\n'
            ' <ol><li><b>Read.</b> Open whatever interests you, scroll as far as it deserves, keep what is worth keeping. '
            '<a href="newsroom.html">Your newsroom</a> shows what the site has learned so far.</li>'
            '<li><b>Look.</b> Below is exactly what would be sent, word for word. Nothing else leaves your browser.</li>'
            '<li><b>Send or copy.</b> Send it with your name and email, encrypted in this browser so that only our list agent can read '
            'it, or copy it and paste it into an email or a message to us.</li></ol>\n'
            ' <sg-meter view="share"><noscript><p>Sharing needs JavaScript: your reading is kept in your browser, and only your browser '
            'can put it together.</p></noscript></sg-meter>\n'
            ' <h2 id="how">Where it goes</h2>\n'
            ' <p>Into the same encrypted, write-only inbox the <a href="../subscribe/index.html">newsletter</a> uses. Your browser '
            'encrypts it to the public key published in <a href="../.well-known/sgit-subscribe.json">/.well-known/sgit-subscribe.json</a> '
            'and drops the ciphertext into a lane that can be written to but not read with anything on this site. The agent that holds '
            'the key files it as a reading share, separately from subscriptions, and it is used for one thing: to design your front '
            'page and reply to you. <a href="../docs/briefs/subscribe-lane-agent-brief.html#reading-share">How the inbox works</a>. '
            'Sending it does not subscribe you to the newsletter unless you tick that box.</p>\n</main>')


def personas_body():
    return ('<main class="doc">\n <p class="crumb"><a href="../index.html">Home</a> / <a href="newsroom.html">Your newsroom</a> / Personas</p>\n'
            ' <p class="eyebrow">SGit Newsroom &middot; yours</p>\n <h1>Your personas</h1>\n'
            ' <p class="lead">You are not one reader. Each persona has its own picks, its own graph and its own list of articles kept '
            'in it or put out of it, and grows from what you read while it is the active one. Start from one of five personas made '
            'from what this site publishes, or from nothing, and give it a name.</p>\n'
            ' <sg-meter view="personas"><noscript><p>Personas need JavaScript: they live in your browser.</p></noscript></sg-meter>\n'
            ' <p class="small dim">Kept in this browser with your balance and history, and exported and restored with them from '
            '<a href="index.html#restore">your account</a>. Anyone who uses this browser can see them; "Start again" on the account '
            'page clears them.</p>\n</main>')


def topup_body():
    return ('<main class="doc">\n <p class="crumb"><a href="../index.html">Home</a> / <a href="../articles/index.html">SGit Newsroom</a> / '
            '<a href="index.html">Your account</a> / Top up</p>\n'
            ' <p class="eyebrow">SGit Newsroom &middot; reading account</p>\n <h1>Top up</h1>\n'
            ' <p class="lead">&pound;5 of reading, paid on Stripe\'s page. You come back to a page that adds the credit to the balance in '
            'this browser. No account, and no subscription: it is one payment, and nothing renews.</p>\n' + METER_NOTE +
            '\n <sg-meter view="topup"><noscript><p>Top-ups need JavaScript: the balance lives in your browser.</p></noscript></sg-meter>\n'
            ' <p class="small dim">The page you come back to cannot check the payment (this site has no server to check it with), so it '
            'adds the credit once for each payment reference it is given. That is a known gap and it is written down, with why it is '
            'accepted, in <a href="../articles/who-will-game-the-reading-meter.html">who will game the reading meter</a>.</p>\n</main>')


def toppedup_body():
    return ('<main class="doc">\n <p class="crumb"><a href="../index.html">Home</a> / <a href="index.html">Your account</a> / Topped up</p>\n'
            ' <p class="eyebrow">SGit Newsroom &middot; reading account</p>\n <h1>Topped up</h1>\n'
            ' <sg-meter view="topped-up"><noscript><p>Adding the credit needs JavaScript: the balance lives in your browser.</p></noscript></sg-meter>\n'
            ' <p class="small dim">This is the page the payment returns to. It adds the top-up to the balance kept in this browser, once '
            'per payment reference, and it does not, and cannot, verify the payment. <a href="../meter/security.html">Why that is '
            'accepted</a>.</p>\n</main>')


def subscribe_body():
    root = '../'
    latest = NEWS.issues[0] if NEWS.issues else None
    fig = ('<figure class="shot nlbanner" data-shot="subscribe.jpg" data-dir="../articles/banners/" '
           'data-alt="Get the next issue of the SGit Newsroom"></figure>\n' if has_banner('subscribe') else '')
    last = (f'<p>The latest issue: <a href="{root}articles/newsletter/{latest["path"]}.html"><b>Issue {latest["number"]}, '
            f'{_esc(latest["title"])}</b></a> ({latest["date"]}). <a href="{root}articles/newsletter/index.html">All issues</a>.</p>'
            if latest else '')
    return ('<main class="doc nlpage">\n'
            f' <p class="crumb"><a href="{root}index.html">Home</a> / <a href="{root}articles/index.html">SGit Newsroom</a> / Subscribe</p>\n'
            + fig +
            ' <p class="eyebrow">SGit Newsroom &middot; the newsletter</p>\n'
            ' <h1>Get the next issue</h1>\n'
            ' <p class="lead">One issue a week or so, when there is enough to say: what was published on sgit.ai, what it '
            'adds up to, and the pieces worth your time. Not an email per article; an issue has the context a single '
            'article does not.</p>\n'
            ' <ul><li><b>What is in an issue.</b> The week\'s articles grouped by what they are about, the arguments in a few '
            'lines each, and quotes checked word for word against the articles when the site is built.</li>'
            '<li><b>What comes next.</b> A personalised issue, picked for the topics you care about, from the same articles '
            'and their graphs.</li>'
            f'<li><b>Where else.</b> Each issue is also a LinkedIn article in {_nl_name()}; the articles have a '
            f'<a href="{root}articles/feed.xml">feed</a>, and agents can read <a href="{root}newsroom/wire.json">the wire</a>.</li></ul>\n'
            + subscribe_block(root) + '\n' + last +
            '\n <h2 id="privacy">What happens to your address</h2>\n'
            ' <p>Your browser encrypts it to the public key of the agent that runs the list, then drops it into a write-only '
            'lane of an encrypted vault. The site never sees it, and nobody without that agent\'s key can read it. '
            f'<a href="{root}docs/briefs/subscribe-lane-agent-brief.html">How the lane works</a>. Every email can be answered '
            'with a request to be removed.</p>\n</main>')


def newsroom_pages():
    out = [('newsroom/index.html', 'How the SGit Newsroom runs, sgit.ai',
            'How the articles on sgit.ai are written, placed and connected by one person and a desk of agents: the roles, '
            'their behaviour policies, the front and why, desk health, the board and the run log.', 'newsroom', newsroom_index_body()),
           ('newsroom/policies.html', 'Behaviour policies, the sgit.ai newsroom',
            'What each newsroom role may write, generated from the role files the policy checker reads.', 'newsroom', newsroom_policies_body()),
           ('newsroom/publish.html', 'How to publish, the sgit.ai newsroom',
            'For any agent writing for sgit.ai: add the article file and it is live; ask for placement with a pitch.', 'newsroom', newsroom_publish_body()),
           ('newsroom/board.html', 'The desk board, the sgit.ai newsroom',
            'The newsroom\'s open work as files, and every pitch with its decision.', 'newsroom', newsroom_board_body()),
           ('newsroom/log.html', 'The desk log, the sgit.ai newsroom',
            'One entry per run of a newsroom role: what changed on the front, what was decided, what is next.', 'newsroom', newsroom_log_body()),
           ('articles/collections/index.html', 'Collections, sgit.ai articles',
            'Articles read together, each set with an introduction saying what it shows that no single article does.', 'collections', collections_index_body()),
           ('articles/desk/index.html', 'From the desk, sgit.ai articles',
            'Short pieces written from the articles: nuggets, threads across articles, and the week in one page.', 'desk', desk_index_body())]
    for r in NEWS.roles:
        out.append((f'newsroom/roles/{r["slug"]}.html', f'{r["title"]}, a newsroom role on sgit.ai', r['mission'], 'newsroom', newsroom_role_body(r)))
    for c in NEWS.collections:
        out.append((f'articles/collections/{c["id"]}.html', f'{c["title"]}, a collection, sgit.ai', c['dek'], 'collections', collection_body(c)))
    out.append(('account/index.html', 'Your reading account, sgit.ai',
                'A reading meter kept in your browser: every page costs a few pence, charged by how much of it you read, from £5.00 of '
                'starting credit. Credit can go negative and nothing is blocked; your history, graph and personas live here too.', 'account', account_body()))
    out.append(('account/newsroom.html', 'Your newsroom, sgit.ai',
                'Your own front page, one persona at a time: picks, what you kept, and the graph your reading has grown. Worked out in '
                'your browser from a history nobody else holds.', 'yours', yours_body()))
    out.append(('account/share.html', 'A newsroom designed for you: send us your reading, sgit.ai',
                'Read as you normally would, then send us what you read, encrypted in your browser or copied into an email, and get back '
                'what your own front page could look like.', 'share', share_body()))
    out.append(('account/personas.html', 'Your personas, sgit.ai',
                'Add, name and switch between reading personas, each with its own picks and graph; start from five made from what '
                'sgit.ai publishes. Kept in your browser.', 'yours', personas_body()))
    out.append(('account/top-up.html', 'Top up your reading account, sgit.ai',
                'Add £5 of reading credit to the balance kept in your browser: one payment on Stripe, no account, nothing renews.',
                'account', topup_body()))
    out.append(('account/topped-up.html', 'Topped up, sgit.ai',
                'The page a top-up returns to: it adds the credit to the balance kept in your browser.', 'account', toppedup_body()))
    out.append(('subscribe/index.html', 'Subscribe to the SGit Newsroom newsletter, sgit.ai',
                'Get the next issue of the SGit Newsroom by email: what was published, what it adds up to, and what is '
                'worth your time, about once a week. Your address is encrypted in your browser.', 'subscribe', subscribe_body()))
    out.append(('articles/newsletter/index.html', 'The newsletter, SGit Newsroom',
                'The regular issue of the SGit Newsroom: what was published, what it adds up to, and the pieces worth '
                'reading, also published on LinkedIn.', 'newsletter', newsletter_index_body()))
    for i in NEWS.issues:
        out.append((f'articles/newsletter/{i["path"]}.html', f'Issue {i["number"]}: {i["title"]}, SGit Newsroom',
                    i['summary'], 'newsletter', issue_body(i)))
    for n in NEWS.notes:
        out.append((f'articles/desk/{n["slug"]}.html', f'{n["title"]}, sgit.ai desk', n['summary'], 'desk', note_body(n)))
    return out


def write_wire():
    """newsroom/wire.json: everything a subscriber agent needs in one fetch. Articles with
    placement, notes with what they cite, collections, the front and its reason."""
    base = 'https://sgit.ai/'
    wire = {
        'site': 'https://sgit.ai', 'generated': datetime.date.today().isoformat(), 'site_version': SITE_VERSION,
        'about': base + 'newsroom/index.html',
        'front': {'edition': NEWS.front['edition'], 'note': NEWS.front['note'],
                  'lead': NEWS.lead(), 'highlights': NEWS.front['highlights'], 'collections': NEWS.front['collections']},
        'topics': [{'id': t[0], 'label': t[1].replace('&amp;', '&'), 'means': t[2]} for t in TOPICS],
        'articles': [{'slug': a['slug'], 'title': a['title'], 'date': a['date'], 'updated': a.get('updated', ''),
                      'author': a['author'], 'url': f'{base}articles/{a["slug"]}.html',
                      'markdown': f'{base}articles/{a["slug"]}.md', 'card': f'{base}articles/cards/{art_card_img(a)}',
                      'graph': (f'{base}articles/graphs/{a["slug"]}.json' if a['slug'] in GRAPHS else None),
                      'teaser': art_teaser(a), 'summary': a['summary'], 'topics': art_topics(a), 'tags': a['tags'],
                      'placement': NEWS.placement_of(a['slug']), 'linkedin': a.get('linkedin') or None,
                      'notes': [n['slug'] for n in NOTES_CITING[a['slug']]]} for a in ARTICLES],
        'notes': [{'slug': n['slug'], 'title': n['title'], 'date': n['date'], 'kind': n['kind'], 'role': n['role'],
                   'summary': n['summary'], 'cites': n['cites'], 'url': f'{base}articles/desk/{n["slug"]}.html',
                   'markdown': f'{base}articles/desk/{n["slug"]}.md'} for n in NEWS.notes],
        'newsletter': [{'number': i['number'], 'slug': i['slug'], 'title': i['title'], 'date': i['date'],
                        'summary': i['summary'], 'cites': i['cites'], 'linkedin': i['linkedin'] or None,
                        'url': f'{base}articles/newsletter/{i["path"]}.html',
                        'banner': (f'{base}{banner_path(i["slug"])}' if has_banner(i['slug']) else None)} for i in NEWS.issues],
        'collections': [{'id': c['id'], 'title': c['title'], 'dek': c['dek'], 'curator': c['curator'],
                         'updated': c['updated'], 'articles': c['slugs'],
                         'url': f'{base}articles/collections/{c["id"]}.html'} for c in NEWS.collections],
    }
    s = json.dumps(wire, indent=2, ensure_ascii=False) + '\n'
    with open(os.path.join(ROOT, 'newsroom', 'wire.json'), 'w') as f:
        f.write(s)
    return s


def write_articles_feed():
    """articles/feed.xml: the articles and the desk notes, newest first. Until v0.6.85 the
    only feed carried the updates, so a feed reader following this site never saw an article."""
    # An article's sort key carries its publishing time when it has one ('2026-10-07T18:07'),
    # so two articles from one day reach a feed reader in the order they were published.
    items = [(a['date'] + (f'T{a["time"]}' if a.get('time') else ''), a['title'], f'https://sgit.ai/articles/{a["slug"]}.html',
              a['summary']) for a in ARTICLES]
    items += [(n['date'], f'{NOTE_KINDS[n["kind"]]}: {n["title"]}', f'https://sgit.ai/articles/desk/{n["slug"]}.html', n['summary'])
              for n in NEWS.notes]
    items += [(i['date'], f'Newsletter, issue {i["number"]}: {i["title"]}', f'https://sgit.ai/articles/newsletter/{i["path"]}.html',
               i['summary']) for i in NEWS.issues]
    items.sort(key=lambda x: x[0], reverse=True)

    def x(s):
        return _esc(s)
    body = ''.join(f'<item><title>{x(t)}</title><link>{u}</link><guid>{u}</guid>'
                   f'<pubDate>{datetime.datetime.fromisoformat(d).strftime("%a, %d %b %Y %H:%M:00 +0000")}</pubDate>'
                   f'<description>{x(s)}</description></item>\n' for d, t, u, s in items[:40])
    feed = ('<?xml version="1.0" encoding="utf-8"?>\n<rss version="2.0"><channel>\n'
            '<title>sgit.ai articles</title><link>https://sgit.ai/articles/index.html</link>\n'
            '<description>Articles and desk notes from sgit.ai, newest first.</description>\n' + body + '</channel></rss>\n')
    with open(os.path.join(ROOT, 'articles', 'feed.xml'), 'w') as f:
        f.write(feed)
    return feed


PAGES = load_pages()

md_total = 0
for path, title, desc, here, body in PAGES:
    page(path, title, desc, here, body)
    md_total += write_md(path, title, desc, body)
print(f'wrote {len(PAGES)} markdown mirrors ({md_total} bytes)')
write_site_index(PAGES)
write_scoped_llms(PAGES)
print('wrote llms-full.txt (%d bytes)' % len(write_llms_full(PAGES)))
print('wrote llms.txt (%d bytes)' % len(write_llms(PAGES)))
print('wrote robots.txt (%d bytes)' % len(write_robots()))
with open(os.path.join(ROOT, 'version.txt'), 'w') as f:
    f.write(SITE_VERSION + '\n')   # read by the freshness check in BOOT
print('wrote sitemap.xml (%d bytes)' % len(write_sitemap(PAGES, BUILD_DATE)))

# The feed and the manifest: derived, never hand-edited, the same rule as the index.
os.makedirs(os.path.join(ROOT, 'updates'), exist_ok=True)
with open(os.path.join(ROOT, 'updates', 'feed.xml'), 'w') as f:
    feed = LOADER.render_feed(UPDATES); f.write(feed)
print(f'wrote updates/feed.xml ({len(feed)} bytes, {min(len(UPDATES), 20)} items)')
manifest = json.dumps({
    'site': 'https://sgit.ai', 'generated': __import__('datetime').date.today().isoformat(), 'site_version': SITE_VERSION,
    'updates': [{k: u[k] for k in ('slug', 'title', 'date', 'version', 'tags', 'summary')}
                 for u in UPDATES],
    'articles': [{k: a[k] for k in ('slug', 'title', 'date', 'version', 'tags', 'summary')}
                 for a in ARTICLES],
}, indent=2, ensure_ascii=False)
with open(os.path.join(ROOT, 'updates', 'updates.json'), 'w') as f:
    f.write(manifest)
print(f'wrote updates/updates.json ({len(manifest)} bytes)')

# The article graphs, published as data. The pages above are rendered from these at build
# time, but a reader, an agent or another site should be able to take the graph itself:
# one file per article at articles/graphs/<slug>.json, and one combined file with the
# topics, the teasers and the links between articles already resolved, so a consumer
# needs one fetch to draw the whole map. Derived, never hand-edited, like updates.json.
os.makedirs(os.path.join(ROOT, 'articles', 'graphs'), exist_ok=True)
_pub_graphs = []
for _a in ARTICLES:
    _g = GRAPHS.get(_a['slug'])
    _entry = {
        'slug': _a['slug'], 'title': _a['title'], 'date': _a['date'], 'updated': _a.get('updated', ''),
        'url': f'https://sgit.ai/articles/{_a["slug"]}.html',
        'markdown': f'https://sgit.ai/articles/{_a["slug"]}.md',
        'card': f'https://sgit.ai/articles/cards/{art_card_img(_a)}',
        'teaser': art_teaser(_a), 'topics': art_topics(_a), 'tags': _a['tags'],
        'links_out': ARTICLE_OUT[_a['slug']], 'links_in': ARTICLE_IN[_a['slug']],
        'graph': ({k: v for k, v in _g.items() if k != 'where'} if _g else None),
    }
    _pub_graphs.append(_entry)
    if _g:
        with open(os.path.join(ROOT, 'articles', 'graphs', _a['slug'] + '.json'), 'w') as f:
            f.write(json.dumps({k: v for k, v in _g.items() if k != 'where'}, indent=2, ensure_ascii=False) + '\n')
_combined = json.dumps({
    'site': 'https://sgit.ai', 'generated': __import__('datetime').date.today().isoformat(), 'site_version': SITE_VERSION,
    'schema': 'https://sgit.ai/articles/graphs.html',
    'topics': [{'id': t[0], 'label': t[1].replace('&amp;', '&'), 'means': t[2]} for t in TOPICS],
    'node_kinds': sorted(LOADER.NODE_KINDS), 'edge_relations': sorted(LOADER.EDGE_RELS),
    'articles': _pub_graphs,
}, indent=2, ensure_ascii=False)
with open(os.path.join(ROOT, 'articles', 'graphs.json'), 'w') as f:
    f.write(_combined + '\n')
print(f'wrote articles/graphs.json ({len(_combined)} bytes, {len(GRAPHS)} graphs) and {len(GRAPHS)} per-article files')
print(f'wrote {BANNER_DIR}/manifest.json ({len(write_banner_manifest())} bytes); render with node admin/build/make_banners.mjs')
print(f'wrote newsroom/wire.json ({len(write_wire())} bytes) and articles/feed.xml ({len(write_articles_feed())} bytes)')
print(f'content: {len(UPDATES)} updates, {len(ARTICLES)} articles')
print(f'newsroom: {len(NEWS.roles)} roles, {len(NEWS.notes)} notes, {len(NEWS.collections)} collections, {len(NEWS.pitches)} pitches, {len(NEWS.findings)} findings')
print('done:', len(PAGES), 'pages', SITE_VERSION)
