openvibers/openvibe.blog Commits

Patch notes v2: commits become stories, and a person publishes them (#131)

OP OpenVibers committed 14 days ago · 51cd82325a · 22 files, +3127 −112

* Patch notes v2: commits become stories, and a person publishes them

The changelog still collects every deployed commit, but it no longer publishes a list of
them. server/patch-notes/ turns a batch into workstreams (intent, a Workstream: trailer or
a shared package; 72-hour rounds; feature, fix, chore, internal; ranked by player impact),
writes the §3.1 post from them (title, dek, three outcomes, one H2 per story, Under the
hood, What's next from open PRs, the full list in a collapsible block), and files it as a
draft. OpenVibe.AI's blog.draft_post writes the prose from the numbered commits plus the
voice card and the two newest published posts when it is configured; otherwise the
deterministic post is filed.

A voice card per author account (blog_voice_cards, the official card seeded) is editable at
Write → How I write. The slop check carries the full banned list and the structures that can
be detected; Blog's copy of the no-looks rule checks titles, deks, text, tags and captions.
The reviewer sees the story map, the blocking issues, the warnings and the checklist, and
posts.publish refuses a draft with a hard gate or no named person's approval. A banned phrase
can be overridden with a reason; looks, [TODO], broken links, missing alt text and the missing
approval cannot. The public feed links a draft's commits only once the post is published.

scripts/patch-notes.js files or prints (--dry-run) the next draft. The Markdown renderer now
renders one safe shape, <details><summary>…</summary>…</details>, so the full list folds away
while everything inside it is still escaped and rendered by the package.

* Patch notes: a width fix says fit, the official card starts private, each package is named

A round whose phone work is all "wider than a phone" gets "Homes that fit a phone",
not "Homes that scroll sideways". The official voice card is seeded private, like every
other card, until its author publishes it. Under the hood names the package and version it
counts (openvibe-shared and openvibe-sdk separately) instead of calling every version
openvibe-shared.

* Patch notes: tag the series, not the internal workstream names

The post's tags are for readers and search; sign-in, phone-home and workstream are how the
pipeline talks to itself. The workstreams stay in the story map and the editor panel.

* Patch notes: cluster by the sso module, not only by the words

A commit that names openvibe-sdk/sso or touches server/auth/sso.js joins the sign-in
workstream even when its subject says something else, as the spec's shared-files rule asks.
The match is word-bounded so "what signing in adds" is not read as the module name, and the
rule stays narrow: a shared CSS file does not merge two stories.

Changes

ddiff --git a/README.md b/README.md
iindex 3d71ac1..7956be0 100644
---- a/README.md
++++ b/README.md
@@@ -4,9 +4,9 @@
**Status:** alpha (roadmap Wave 16, Blog half). **Public at https://openvibe.blog since 2026-09-23**
(the launch release also removed the domain from OpenVibe.Sites). Its capabilities and service
--manifest are released in openvibe-contracts v0.97.0. The network changelog publishes Patch notes
--posts on the official blog ([below](#the-network-changelog-and-patch-notes)); the seed post stays a
--draft until a person reviews it.
++manifest are released in openvibe-contracts v0.97.0. The network changelog files Patch notes
++**drafts** on the official blog ([below](#the-network-changelog-and-patch-notes)); a person reviews
++and publishes each one, and the seed post stays a draft until a person reviews it.
**Domain:** `openvibe.blog` · **Port:** 4810 · **Service id:** `blog`
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.6; roadmap §15.13, §29, §32.
**License:** AGPL-3.0 (same as every OpenVibe service).
@@@ -194,14 +194,37 @@ repository and current release from OpenVibe.Network's registry and takes the ne
compare API. The first time it sees a site it also imports that site's last `CHANGELOG_HISTORY` commits
(60 by default). Those history entries are shown in the feed but never go into a post.
--A **Patch notes** post on the official blog (series "Patch notes", imported authorship, grouped by
--site) is published when either of these happens:
++A **Patch notes draft** on the official blog (series "Patch notes") is filed when either of these
++happens:
- `CHANGELOG_BATCH_SIZE` changes have gathered.
- A large release (`CHANGELOG_MAJOR_LINES`, a version bump or `[major]`) has been quiet for 30 minutes.
--There is always at least a 6-hour gap between posts. An AI-written dev-blog version is filed as a draft
--for review.
++There is always at least a 6-hour gap between drafts. Nothing is published from the pipeline: the
++draft is the only artifact, and publishing it needs a person's approval of the revision. See
++`server/patch-notes/`:
++
++- **stories** — the commits become workstreams (sign-in, touch, phone-home, orbit, …), clustered by
++ intent, a `Workstream:` trailer or a shared package, split at 72 hours, classified feature / fix /
++ chore / internal and ranked by player impact. Lockfiles and version bumps fold into one "Under the
++ hood" paragraph.
++- **draft** — the §3.1 template: a title and dek, three player outcomes, one H2 per story with what
++ changed, why and how to see it, Under the hood, What's next from the open PRs (at most three, each
++ linked), and the full commit list under `<details>`. OpenVibe.AI's `blog.draft_post` writes the
++ prose from the numbered commits plus the voice card and the two newest published posts; without AI
++ the deterministic post is filed.
++- **voice** — one card per author account (`blog_voice_cards`; `official` is seeded), edited at
++ **Write → How I write**. It is private unless the author publishes it.
++- **slop** and **no-looks** — the banned phrases and the structures (a three-item list in every
++ paragraph, a restating conclusion, an em dash every other sentence), and the owner's rule that
++ public AI text never describes a person. The editor shows both.
++- **review** — the checks and the checklist the reviewer sees, and the hard gates that stop
++ `posts.publish`: a banned phrase (unless the editor overrides it with a reason), a looks word, an
++ unresolved `[TODO]`, a broken link, an image without alt text, AI-only text without approval, or
++ no named person's approval at all.
++
++`node scripts/patch-notes.js` files a draft from the pending commits (`--dry-run` prints it,
++`--no-ai` uses the deterministic post).
`GET /api/v1/changelog?service=&limit=&before=` is public (CORS `*`, cached for 60 s). It returns:
ddiff --git a/migrations/0005_patch_notes_v2.sql b/migrations/0005_patch_notes_v2.sql
nnew file mode 100644
iindex 0000000..3420389
---- /dev/null
++++ b/migrations/0005_patch_notes_v2.sql
@@@ -0,0 +1,52 @@
++-- phase: expand
++-- Patch notes v2 (server/patch-notes/): the stories a batch becomes, the checks a reviewer sees, and the card an
++-- author account writes in.
++--
++-- blog_voice_cards one card per author account ('official' is the account-wide seed, a usr_ row overrides it)
++-- blog_patch_note_drafts the story map and the checks of one Patch notes draft (post_id → blog_posts.id)
++-- changelog_entries + the commit body, the files it touched and its PR, read once and kept (the body is where
++-- the measurements are; the file list is how stories that share a package find each other)
++
++CREATE TABLE IF NOT EXISTS blog_voice_cards (
++ account text COLLATE "C" PRIMARY KEY,
++ card jsonb NOT NULL,
++ public integer NOT NULL DEFAULT 0,
++ updated_at bigint NOT NULL
++);
++
++INSERT INTO blog_voice_cards (account, card, public, updated_at)
++VALUES (
++ 'official',
++ jsonb_build_object(
++ 'text', 'We. Energetic and specific: we''re proud of this work and show it with the thing itself (a number, a before/after, what you can do now), never with adjectives. Short sentences. One dry joke at most, never in the first paragraph. One exclamation mark at most per section. Sign off: — the OpenVibe crew.',
++ 'name', 'OpenVibe official',
++ 'role', 'The network account',
++ 'pov', 'we',
++ 'audience', 'Members and contributors, not investors',
++ 'sentenceLength', 'Short. One idea per sentence.',
++ 'humour', 'Dry. One joke at most, never in the first paragraph.',
++ 'jargon', 'None in the story; exact names in the full list.',
++ 'never', jsonb_build_array('excited', 'thrilled', 'game-changing', 'seamless as filler'),
++ 'signoff', '— the OpenVibe crew'
++ ),
++ 0,
++ (extract(epoch FROM now()) * 1000)::bigint
++)
++ON CONFLICT (account) DO NOTHING;
++
++CREATE TABLE IF NOT EXISTS blog_patch_note_drafts (
++ post_id text COLLATE "C" PRIMARY KEY,
++ blog_id text COLLATE "C" NOT NULL,
++ stories jsonb NOT NULL DEFAULT '[]'::jsonb,
++ entries jsonb NOT NULL DEFAULT '[]'::jsonb,
++ checks jsonb,
++ voice_card jsonb,
++ override jsonb,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS blog_patch_note_drafts_updated ON blog_patch_note_drafts (updated_at DESC);
++
++ALTER TABLE changelog_entries ADD COLUMN IF NOT EXISTS message_body text;
++ALTER TABLE changelog_entries ADD COLUMN IF NOT EXISTS files jsonb;
++ALTER TABLE changelog_entries ADD COLUMN IF NOT EXISTS pr_number integer;
ddiff --git a/scripts/patch-notes.js b/scripts/patch-notes.js
nnew file mode 100644
iindex 0000000..ed8ea2f
---- /dev/null
++++ b/scripts/patch-notes.js
@@@ -0,0 +1,45 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * File the next Patch notes draft from the commits since the last one. A DRAFT: it is never published here.
++ *
++ * node scripts/patch-notes.js # the pending commits, AI prose when OpenVibe.AI is configured
++ * node scripts/patch-notes.js --no-ai # the deterministic post
++ * node scripts/patch-notes.js --dry-run # print the post, write nothing
++ * node scripts/patch-notes.js --reason manual # what the changelog row records
++ *
++ * Runs with the service's environment (DATABASE_URL, OV_OAUTH_CLIENT_SECRET, OV_AI_INTERNAL_URL) and its outbox.
++ */
++const { createApp } = require('../server/app');
++
++const args = process.argv.slice(2);
++const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
++
++(async () => {
++ const { ctx } = await createApp();
++ try {
++ const reason = opt('reason') || 'manual';
++ if (args.includes('--dry-run')) {
++ const blog = await ctx.blogs.byHandle(ctx.config.changelog.blogHandle) || await ctx.blogs.ensureOfficial();
++ let entries = await ctx.changelog.pendingEntries();
++ entries = await ctx.changelog.enrich(entries);
++ const plan = await ctx.patchNotes.plan({ entries, blog, useAi: !args.includes('--no-ai') });
++ if (!plan) { console.log('nothing pending'); return; }
++ console.log(`title: ${plan.doc.title}\ndek: ${plan.doc.dek}\ntags: ${plan.doc.tags.join(', ')}\n\n${plan.doc.body}`);
++ const b = plan.checks.blocking;
++ console.log(`\n---\n${b.length} blocking, ${plan.checks.warnings.length} warnings${plan.usedAi ? ' (AI prose)' : ''}${b.length ? `: ${[...new Set(b.map((x) => x.code))].join(', ')}` : ''}`);
++ return;
++ }
++ const out = await ctx.patchNotes.draftPending({ reason, useAi: !args.includes('--no-ai') });
++ if (!out.created) { console.log(`no draft: ${out.reason}`); return; }
++ const b = out.checks.blocking;
++ console.log(`draft: ${out.post.id} (${out.post.state}) ${out.url}`);
++ console.log(`${b.length} blocking, ${out.checks.warnings.length} warnings${out.usedAi ? ' (AI prose)' : ''}${b.length ? `: ${[...new Set(b.map((x) => x.code))].join(', ')}` : ''}`);
++ console.log('left as a draft: a person reviews it in /write, then approves the revision and publishes.');
++ } catch (err) {
++ console.error(`[patch-notes] ${err.message}`);
++ process.exitCode = 1;
++ } finally {
++ await ctx.store.close();
++ }
++})();
ddiff --git a/server/app.js b/server/app.js
iindex 9c6a06f..608dd27 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -96,10 +96,15 @@ async function createApp(opts = {}) {
await blogs.ensureOfficial();
const aiDrafts = opts.aiDrafts || require('./domain/ai-drafts').createAiDrafts({ config, store, posts, access, fetchImpl });
-- const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, aiDrafts, fetchImpl, log });
++ // Patch notes v2: the story map, the template, the voice cards and the review gate. The changelog files drafts
++ // through it; posts.publish refuses a draft with a blocking check or no person's approval.
++ const patchNotes = opts.patchNotes || require('./patch-notes').createPatchNotes({ config, store, blogs, posts, publication, aiDrafts, fetchImpl, log });
++ const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, patchNotes, fetchImpl, log });
++ if (patchNotes.attach) patchNotes.attach({ changelog });
++ posts.setPublishGate(patchNotes.gate);
// The AI desks (server/desks/): digests from Sources' items and the satire desk, beside the blogs.
const desks = opts.desks || require('./desks/desks').createDesks({ config, store, fetchImpl, log });
-- const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, desks, indexnow };
++ const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, patchNotes, desks, indexnow };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/changelog.js b/server/changelog.js
iindex 3f4996a..4285586 100644
---- a/server/changelog.js
++++ b/server/changelog.js
@@@ -11,16 +11,14 @@
* library version bump like "1.7.0: …", or "[major]" in a message) marks its entries major. A service
* seen for the first time only records its release, unless CHANGELOG_SINCE backfills from a date.
*
-- * Publish: pending entries (not yet in a post) become one "Patch notes" post on the blog
++ * Draft: pending entries (not yet in a draft) become one Patch notes DRAFT on the blog
* (CHANGELOG_BLOG, default the official blog) when there are CHANGELOG_BATCH_SIZE of them, or when a
* major release is pending and deploys have been quiet for CHANGELOG_QUIET_MS (a burst of deploys
-- * becomes one post), or when the oldest has waited CHANGELOG_MAX_AGE_MS; never sooner than
-- * CHANGELOG_MIN_GAP_MS after the last post unless twice the batch is waiting. The post is the commit
-- * messages people wrote, grouped by site with links to each commit and the highlights first:
-- * authorship "imported" (from GitHub), published at once by the blog's first configured owner, so it
-- * reaches feeds, Search and Community Pulse like any post. With OpenVibe.AI configured and
-- * CHANGELOG_AI_DRAFT on, an AI-written dev-blog version is also filed as a draft for a person to
-- * review (AI text is never published unreviewed).
++ * becomes one draft), or when the oldest has waited CHANGELOG_MAX_AGE_MS; never sooner than
++ * CHANGELOG_MIN_GAP_MS after the last draft unless twice the batch is waiting (devblog §3.6: the
++ * pipeline never publishes). server/patch-notes/ turns the commits into workstreams and the §3.1
++ * template, with OpenVibe.AI's prose when it is configured, and a person reviews the draft before it
++ * can be published. The draft owns its entries; the public feed links them only once the post is live.
*
* Read: GET /api/v1/changelog?service=&limit= (public, CORS *) is what every site's "recently
* shipped" shows, with the latest patch notes post to link to.
@@@ -53,11 +51,11 @@ function subjectOf(message, n = 240) {
const isVersionBump = (subject) => /^\d+\.\d+\.\d+\b/.test(subject);
const isNoise = (subject) => /^Merge (branch|pull request|remote)/i.test(subject);
--function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchImpl = globalThis.fetch, log = console }) {
++function createChangelog({ config, store, blogs, posts, patchNotes = null, fetchImpl = globalThis.fetch, log = console }) {
const c = config.changelog;
const db = store.db;
const nowMs = () => store.now();
-- const stats = { runs: 0, entries: 0, posts: 0, lastError: null, lastRunAt: null };
++ const stats = { runs: 0, entries: 0, posts: 0, drafts: 0, lastError: null, lastRunAt: null };
const q = {
state: db.prepare('SELECT * FROM changelog_state WHERE service = ?'),
@@@ -72,6 +70,7 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
sites: db.prepare(`SELECT service, COUNT(*) AS entries, MAX(deployed_at) AS latest_at FROM changelog_entries GROUP BY service ORDER BY latest_at DESC`),
pending: db.prepare('SELECT * FROM changelog_entries WHERE post_id IS NULL ORDER BY deployed_at, committed_at, id'),
mark: db.prepare('UPDATE changelog_entries SET post_id = ? WHERE id = ? AND post_id IS NULL'),
++ enrich: db.prepare('UPDATE changelog_entries SET message_body = ?, files = ?, pr_number = ? WHERE service = ? AND sha = ?'),
lastPost: db.prepare('SELECT * FROM changelog_posts ORDER BY created_at DESC LIMIT 1'),
putPost: db.prepare(`INSERT INTO changelog_posts (post_id, blog_id, entries, from_at, to_at, reason, ai_draft_id, created_at)
VALUES (@post_id, @blog_id, @entries, @from_at, @to_at, @reason, @ai_draft_id, @created_at)`),
@@@ -229,73 +228,76 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
return { due: false, reason: 'waiting' };
}
-- function render(list, services) {
-- const byService = new Map();
-- for (const e of list) { if (!byService.has(e.service)) byService.set(e.service, []); byService.get(e.service).push(e); }
-- const order = [...byService.keys()].sort((a, b) => byService.get(b).length - byService.get(a).length || a.localeCompare(b));
-- const name = (id) => (services.get(id) && services.get(id).name) || id;
-- const link = (e) => `[\`${e.sha.slice(0, 7)}\`](https://github.com/${e.repo}/commit/${e.sha})`;
-- // A highlight per major release: its most descriptive (longest) commit subject.
-- const majors = new Map();
-- for (const e of list) if (e.major) { const k = `${e.service}@${e.release}`; if (!majors.has(k) || e.subject.length > majors.get(k).subject.length) majors.set(k, e); }
-- const highlights = [...majors.values()].slice(0, 8);
-- const day = (iso) => new Date(iso).toISOString().slice(0, 10);
-- const from = day(list[0].deployed_at);
-- const to = day(list[list.length - 1].deployed_at);
-- const sites = order.map(name);
-- const lead = highlights[0] ? clean(highlights[0].subject.split(/[:.;(]/)[0], 70) : null;
-- // The day ends the title: titles made from the counts repeated ("Patch notes: 80 changes across 36 sites" three
-- // times by 2026-10-17), which search results and the feed cannot tell apart. The "Patch notes: " start stays
-- // (openvibe-shared/shipped.js strips it to show the rest).
-- const dayLabel = new Date(list[list.length - 1].deployed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
-- const title = `Patch notes: ${lead && lead.length >= 12 ? lead : `${list.length} changes across ${order.length} ${order.length === 1 ? 'site' : 'sites'}`} (${dayLabel})`;
-- const summary = clean(`What shipped on OpenVibe ${from === to ? `on ${from}` : `from ${from} to ${to}`}: ${list.length} ${list.length === 1 ? 'change' : 'changes'} to ${sites.slice(0, 6).join(', ')}${sites.length > 6 ? ` and ${sites.length - 6} more` : ''}.`, 400);
-- const out = [`${summary} Every line below is a commit message from the OpenVibers repositories, linked to the change itself.`, ''];
-- if (highlights.length) {
-- out.push('## Highlights', '');
-- for (const e of highlights) out.push(`- **${name(e.service)}:** ${e.subject} (${link(e)})`);
-- out.push('');
++ /**
++ * Read the body, the files and the PR of commits that do not have them yet. The body is where the measurements
++ * live; the files are how a story that shared a package is found. Kept in the row, so a later draft does not ask
++ * GitHub again. Failures are not fatal: the draft is written from what is known.
++ */
++ async function enrich(list) {
++ const out = [];
++ for (const e of list) {
++ if (e.message_body || e.body) { out.push({ ...e, body: e.body || e.message_body }); continue; }
++ try {
++ const c = await gh(`/repos/${e.repo}/commits/${e.sha}`);
++ const body = String((c.commit && c.commit.message) || '');
++ const files = (Array.isArray(c.files) ? c.files : []).map((f) => f.filename).slice(0, 60);
++ const pr = Number((e.subject.match(/\(#(\d+)\)\s*$/) || [])[1]) || null;
++ await q.enrich.run(body, JSON.stringify(files), pr, e.service, e.sha);
++ out.push({ ...e, body, files, pr_number: pr });
++ } catch (err) {
++ if (err.status === 403 || err.status === 429) { out.push(...list.slice(out.length)); break; }
++ out.push({ ...e, body: e.subject });
++ }
}
-- for (const id of order) {
-- const svc = services.get(id);
-- out.push(`## ${name(id)}`, '');
-- if (svc && svc.origin) out.push(`[${svc.origin.replace(/^https?:\/\//, '')}](${svc.origin}) · [repository](https://github.com/${svc.repo})`, '');
-- for (const e of byService.get(id)) out.push(`- ${e.subject} (${link(e)})`);
-- out.push('');
++ return out;
++ }
++
++ /** The pending commits, oldest first, with their URL: what a draft is made from. */
++ async function pendingEntries() {
++ return (await q.pending.all()).map((e) => ({ ...e, url: `https://github.com/${e.repo}/commit/${e.sha}` }));
++ }
++
++ /** A draft owns its commits (they are not in a second draft); the feed shows the link once the post publishes. */
++ async function markEntries(list, postId) {
++ for (const e of list) if (e.id != null) await q.mark.run(postId, e.id);
++ }
++
++ /**
++ * The open PRs of the organisation, for What's next. Search API, ten minutes cached: a rate limit answers [].
++ */
++ let pullsCache = { at: 0, value: [] };
++ async function openPulls() {
++ if (nowMs() - pullsCache.at < 10 * 60 * 1000) return pullsCache.value;
++ try {
++ const r = await gh(`/search/issues?q=${encodeURIComponent('org:OpenVibers type:pr state:open')}&per_page=50&sort=updated`);
++ const value = (Array.isArray(r.items) ? r.items : []).map((i) => ({
++ number: i.number, title: i.title, url: i.html_url,
++ repo: String(i.repository_url || '').split('/').slice(-2).join('/'),
++ })).filter((p) => p.number && p.url);
++ pullsCache = { at: nowMs(), value };
++ return value;
++ } catch (err) {
++ stats.lastError = `pulls: ${err.message}`;
++ return pullsCache.value;
}
-- out.push('---', '', 'Patch notes are put together automatically when enough changes have shipped, or when a large feature lands. See every site\'s own updates page for the live list.');
-- return { title, summary, body: out.join('\n'), from: list[0].deployed_at, to: list[list.length - 1].deployed_at, tags: ['patch-notes', ...order.slice(0, 10)] };
}
-- async function publish(list, reason) {
-- const blog = await blogs.byHandle(c.blogHandle) || (c.blogHandle === config.official.handle ? await blogs.ensureOfficial() : null);
-- const owner = config.official.owners.find((s) => /^usr_/.test(s));
-- if (!blog || !owner) { stats.lastError = !blog ? `no blog @${c.blogHandle}` : 'BLOG_OFFICIAL_OWNERS names no owner to publish as'; return null; }
-- const services = new Map((await registry().catch(() => [])).map((s) => [s.id, s]));
-- for (const e of list) if (!services.has(e.service)) services.set(e.service, { id: e.service, name: e.service, repo: e.repo, origin: null });
-- const doc = render(list, services);
-- const viewer = { kind: 'user', subject: owner, staff: false };
-- const { post } = await posts.create(viewer, blog, {
-- title: doc.title, summary: doc.summary, body: doc.body, tags: doc.tags.filter((t) => t.length <= 50).slice(0, 20), series: 'Patch notes',
-- authorship: { mode: 'imported', importedFrom: { label: 'Commit messages from the OpenVibers repositories on GitHub', originalAuthor: 'OpenVibers' } },
-- message: `Patch notes from ${list.length} commits (${reason})`,
-- });
-- await posts.publish(viewer, posts.get ? await posts.get(post.id) || post : post);
-- let aiDraftId = null;
-- if (c.aiDraft && aiDrafts && aiDrafts.enabled) {
-- try {
-- const brief = list.map((e) => `- [${e.service}] ${e.subject}`).join('\n').slice(0, 3900);
-- const d = await aiDrafts.draft(viewer, blog, { topic: `Dev blog: ${doc.title.replace(/^Patch notes: /, '')}`, brief: `Write a friendly developer-blog post about what just shipped on OpenVibe, for its community. Only use these changes:\n${brief}`, tone: 'plain, warm, specific', audience: 'OpenVibe members and contributors' });
-- aiDraftId = d.post.id;
-- } catch (err) { log.warn && log.warn(`[changelog] AI dev-blog draft not made: ${err.message}`); }
++ /** File the draft: Patch notes are never published from here (devblog §3.6). */
++ async function fileDraft(list, reason) {
++ if (!patchNotes) return null;
++ try {
++ const out = await patchNotes.draftPending({ entries: list, reason });
++ if (out && out.created) {
++ stats.drafts++;
++ await q.putPost.run({ post_id: out.post.id, blog_id: out.post.blog_id, entries: list.length, from_at: list[0].deployed_at, to_at: list[list.length - 1].deployed_at, reason, ai_draft_id: null, created_at: nowMs() });
++ log.log && log.log(`[changelog] filed Patch notes draft ${out.post.id} (${list.length} entries, ${reason})`);
++ }
++ return out;
++ } catch (err) {
++ stats.lastError = `draft: ${err.message}`;
++ log.warn && log.warn(`[changelog] Patch notes draft not filed: ${err.message}`);
++ return null;
}
-- await store.tx(async () => {
-- for (const e of list) await q.mark.run(post.id, e.id);
-- await q.putPost.run({ post_id: post.id, blog_id: blog.id, entries: list.length, from_at: doc.from, to_at: doc.to, reason, ai_draft_id: aiDraftId, created_at: nowMs() });
-- });
-- stats.posts++;
-- log.log && log.log(`[changelog] published ${post.id} (${list.length} entries, ${reason})`);
-- return { post, aiDraftId };
}
async function tick() {
@@@ -304,7 +306,7 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
await collect();
const list = await q.pending.all();
const d = await due(list);
-- if (d.due) return await publish(list.slice(0, c.batchSize * 3), d.reason);
++ if (d.due) return await fileDraft(list.slice(0, c.batchSize * 3), d.reason);
return null;
} catch (err) { stats.lastError = err.message; log.warn && log.warn(`[changelog] ${err.message}`); return null; }
}
@@@ -327,13 +329,15 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
const args = [];
if (service) { where.push('service = ?'); args.push(String(service)); }
if (cur) { where.push('(deployed_at < ? OR (deployed_at = ? AND id < ?))'); args.push(cur.at, cur.at, cur.id); }
-- const rows = await db.prepare(`SELECT * FROM changelog_entries ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY deployed_at DESC, id DESC LIMIT ?`).all(...args, n + 1);
++ const rows = await db.prepare(`SELECT e.*, (SELECT p.state FROM blog_posts p WHERE p.id = e.post_id) AS post_state
++ FROM changelog_entries e ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY e.deployed_at DESC, e.id DESC LIMIT ?`).all(...args, n + 1);
const more = rows.length > n;
const shown = rows.slice(0, n);
return {
entries: shown.map((e) => ({
service: e.service, sha: e.sha, short: e.sha.slice(0, 7), subject: e.subject, author: e.author || null, committed_at: e.committed_at, deployed_at: e.deployed_at,
-- major: Boolean(e.major), url: `https://github.com/${e.repo}/commit/${e.sha}`, post_id: e.post_id && e.post_id !== 'history' ? e.post_id : null,
++ major: Boolean(e.major), url: `https://github.com/${e.repo}/commit/${e.sha}`,
++ post_id: e.post_id && e.post_id !== 'history' && e.post_state === 'published' ? e.post_id : null,
})),
next: more && shown.length ? encodeCursor(shown[shown.length - 1]) : null,
};
@@@ -344,7 +348,7 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
/** Every site with entries: [{ service, entries, latest_at }], most recently shipped first. */
async function sites() { return await q.sites.all(); }
-- return { tick, collect, due, render, publish, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: async () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: (await q.pending.all()).length }) };
++ return { tick, collect, due, fileDraft, enrich, pendingEntries, markEntries, openPulls, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: async () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: (await q.pending.all()).length }) };
}
/** A stable id for a batch (tests). */
ddiff --git a/server/config.js b/server/config.js
iindex b2d4f28..d6c7561 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -59,7 +59,14 @@ function load(env = process.env) {
history: Math.max(0, Math.min(100, parseInt(env.CHANGELOG_HISTORY ?? '60', 10) || 0)),
firstDelayMs: 60 * 1000,
since: env.CHANGELOG_SINCE && !Number.isNaN(Date.parse(env.CHANGELOG_SINCE)) ? new Date(env.CHANGELOG_SINCE).toISOString() : null,
-- aiDraft: env.CHANGELOG_AI_DRAFT !== 'off',
++ },
++ // Patch notes v2 (server/patch-notes/): how many workstreams get an H2, and how many published posts the AI
++ // prompt gets as voice samples. The draft is always a draft; CHANGELOG_AI_DRAFT=off skips the AI prose and
++ // writes the deterministic one.
++ patchNotes: {
++ maxStories: Math.max(2, Math.min(8, int(env.BLOG_PATCH_NOTES_STORIES, 6))),
++ samples: Math.max(0, Math.min(4, int(env.BLOG_PATCH_NOTES_SAMPLES, 2))),
++ ai: env.CHANGELOG_AI_DRAFT !== 'off',
},
// OpenVibe.AI for "Draft with AI" (server/domain/ai-drafts.js); empty = the button is not offered.
aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
ddiff --git a/server/domain/ai-drafts.js b/server/domain/ai-drafts.js
iindex 9bbefde..391bd4f 100644
---- a/server/domain/ai-drafts.js
++++ b/server/domain/ai-drafts.js
@@@ -23,6 +23,43 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
}) : null;
const ai = createAiClient({ baseUrl: config.aiUrl, tokenClient, fetch: fetchImpl });
++ /** The numbered sources, clipped to the workflow's schema (source_type is required, 2–64 chars). */
++ function sourcesOf(list) {
++ return (Array.isArray(list) ? list : []).slice(0, 50).map((s) => {
++ const out = { source_type: clean(s && s.source_type, 64) || 'source' };
++ for (const [k, n] of [['source_id', 256], ['url', 2048], ['title', 500], ['author', 200], ['published_at', 40], ['retrieved_at', 40], ['observed_at', 40]]) {
++ if (s && s[k] != null && String(s[k]) !== '') out[k] = String(s[k]).slice(0, n);
++ }
++ if (s && s.content != null) out.content = String(s.content).slice(0, 20000);
++ else if (s && s.snippet != null) out.snippet = String(s.snippet).slice(0, 4000);
++ return out;
++ });
++ }
++
++ /**
++ * One AI run, no post written: Patch notes composes the post itself (title, list, sign-off). Returns the raw
++ * answer and the workflow record for the authorship of the post it creates.
++ */
++ async function rawDraft(viewer, blog, input = {}, { traceparent } = {}) {
++ if (!viewer || !viewer.subject) throw new ApiError(401, 'subject.required', 'Sign in to ask for an AI draft');
++ if (!await access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
++ const topic = clean(input.topic, 300);
++ if (!topic) throw new ApiError(422, 'ai_draft.topic_required', 'Say what the post should be about');
++ const brief = String(input.brief == null ? '' : input.brief).trim().slice(0, 4000);
++ const tone = clean(input.tone, 200);
++ const audience = clean(input.audience, 300);
++ const sources = sourcesOf(input.sources);
++ try {
++ const r = await ai.run('blog.draft_post', { topic, ...(brief ? { brief } : {}), ...(tone ? { tone } : {}), ...(audience ? { audience } : {}), ...(sources.length ? { sources } : {}) }, {
++ onBehalfOf: { type: 'user', id: viewer.subject }, target: { service: 'blog', type: 'blog', id: String(blog.id) }, traceparent,
++ });
++ return { runId: r.runId, output: r.output, workflow: r.workflow, synthetic: r.synthetic, citations: r.citations };
++ } catch (err) {
++ if (err instanceof AiRunError) throw new ApiError(err.status || 502, err.code, err.message);
++ throw err;
++ }
++ }
++
async function draft(viewer, blog, input = {}, { traceparent } = {}) {
if (!viewer || !viewer.subject) throw new ApiError(401, 'subject.required', 'Sign in to ask for an AI draft');
if (!await access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
@@@ -60,7 +97,7 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
}, { traceparent });
}
-- return { enabled: ai.enabled, draft };
++ return { enabled: ai.enabled, draft, rawDraft };
}
module.exports = { createAiDrafts };
ddiff --git a/server/domain/posts.js b/server/domain/posts.js
iindex 3900705..ca1066e 100644
---- a/server/domain/posts.js
++++ b/server/domain/posts.js
@@@ -174,6 +174,10 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
// ── Reads ───────────────────────────────────────────────
++ // Patch notes v2 (server/patch-notes/index.js): refuses a publish the reviewer's checks block.
++ let publishGate = null;
++ function setPublishGate(fn) { publishGate = typeof fn === 'function' ? fn : null; }
++
const api = {
VISIBILITIES, MEDIA_ROLES,
@@@ -345,6 +349,13 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
if (post.state === 'published' && post.published_revision === rev.number) return { changed: false, post };
const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's review before it can be published (${ok.reason})`);
++ if (publishGate) {
++ const gate = await publishGate(post, rev);
++ if (gate && gate.blocking && gate.blocking.length) {
++ const codes = [...new Set(gate.blocking.map((b) => b.code))].join(', ');
++ throw new ApiError(409, 'post.patch_notes_blocked', `This Patch notes draft has ${gate.blocking.length} blocking problem${gate.blocking.length === 1 ? '' : 's'} (${codes}). Fix them, or have the editor override a banned phrase with a reason, then publish.`);
++ }
++ }
const before = publication.snapshot(await blogOf(post), post);
const now = store.now();
await db.prepare(`UPDATE blog_posts SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),
@@@ -518,6 +529,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
return await store.attachments.attach({ entityId: post.id, mediaId: id, role, alt: alt || null, caption: caption || null, position: Number.isInteger(pos) ? pos : 0 });
},
++ setPublishGate,
++
async detach(viewer, post, attachmentId) {
actingSubject(viewer);
if (!await access.canWrite(store, viewer, await blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex cdeeb50..b50f099 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -62,7 +62,7 @@ function createEditorRoutes(ctx) {
router.post('/@:handle/settings', B('blog.blog.configure'));
router.post(['/@:handle/members', '/@:handle/members/remove'], B('blog.member.manage'));
router.post(['/posts/:id', '/posts/:id/revert'], B('blog.post.update'));
-- router.post(['/posts/:id/publish', '/posts/:id/schedule', '/posts/:id/unschedule', '/posts/:id/unpublish', '/posts/:id/review'], B('blog.post.publish'));
++ router.post(['/posts/:id/publish', '/posts/:id/schedule', '/posts/:id/unschedule', '/posts/:id/unpublish', '/posts/:id/review', '/posts/:id/checks'], B('blog.post.publish'));
router.post('/posts/:id/delete', B('blog.post.delete'));
router.post(['/posts/:id/media', '/posts/:id/media/:aid/remove'], B('blog.post.media'));
router.post('*', form, (req, res, next) => {
@@@ -87,6 +87,13 @@ function createEditorRoutes(ctx) {
}
const csrf = (req) => csrfToken(config, req.viewer);
++ // A link resolves when it answers 2xx (HEAD, 6s). The review gate's HTTP check, in the editor only.
++ const verifyLink = async (url) => {
++ try {
++ const r = await globalThis.fetch(url, { method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(6000) });
++ return r.status >= 200 && r.status < 400;
++ } catch { return false; }
++ };
const flashOf = (req) => (req.query.saved ? { kind: 'ok', text: String(req.query.saved).slice(0, 200) } : null);
async function blogFor(req) {
@@@ -205,8 +212,9 @@ function createEditorRoutes(ctx) {
const review = await publication.reviewOf(post, head);
const terms = await publication.termsOf(post);
const decision = await publication.decide(blog, { ...post, state: 'published' }, head);
++ const patchNotes = ctx.patchNotes ? await ctx.patchNotes.load(post.id) : null;
const body = editor.editPage({
-- blog, post, head, published, decision, jobs: await posts.pendingJobs(post), attachments: await posts.attachments(post), csrf: csrf(req),
++ blog, post, head, published, decision, patchNotes, jobs: await posts.pendingJobs(post), attachments: await posts.attachments(post), csrf: csrf(req),
disclosure: rec ? authorship.disclosure(rec, review) : null,
needsReview: rec ? !authorship.canPublish(rec, review).ok : false,
publicUrl: publication.postPath(blog, post),
@@@ -253,6 +261,14 @@ function createEditorRoutes(ctx) {
return await posts.remove(req.viewer, post, tp(req));
}, 'Deleted.');
action('review', async (req, post, b) => await posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note || null }, tp(req)), (out) => `Review recorded: ${out.decision}.`);
++ action('checks', async (req, post) => {
++ if (!ctx.patchNotes) return { blocking: [], warnings: [] };
++ const phrases = req.body && req.body.phrases ? [].concat(req.body.phrases) : [];
++ const reason = req.body && req.body.reason ? String(formInput(req.body).reason || '') : '';
++ if (reason && phrases.length) await ctx.patchNotes.setOverride(post.id, { reason, phrases, by: req.viewer.subject });
++ const out = await ctx.patchNotes.check(post.id, { verifyLinks: verifyLink });
++ return out ? out.result : { blocking: [], warnings: [] };
++ }, (out) => `Checks run: ${((out && out.blocking) || []).length} blocking, ${((out && out.warnings) || []).length} warnings.`);
action('revert', async (req, post, b) => await posts.revert(req.viewer, post, { toRevision: b.toRevision, expectedRevision: b.expectedRevision }), (out) => `Restored as revision ${out.revision.number}.`);
action('media', async (req, post, b) => await posts.attach(req.viewer, post, { mediaId: b.mediaId, role: b.role, alt: b.alt, caption: b.caption }), 'Media attached.');
router.post('/posts/:id/media/:aid/remove', wrap(async (req, res) => {
@@@ -262,6 +278,18 @@ function createEditorRoutes(ctx) {
res.redirect(303, `/write/posts/${found.post.id}?saved=${encodeURIComponent('Media removed.')}`);
}));
++ // Settings → How I write: the voice card the AI prompt uses.
++ router.get('/voice', wrap(async (req, res) => {
++ const mine = await ctx.patchNotes.voice.get(req.viewer.subject);
++ const official = await ctx.patchNotes.voice.official();
++ page(req, res, 'How I write', editor.voicePage({ viewer: req.viewer, mine, official, csrf: csrf(req), message: flashOf(req) }));
++ }));
++ router.post('/voice', wrap(async (req, res) => {
++ const b = formInput(req.body);
++ await ctx.patchNotes.voice.set(req.viewer.subject, { ...b, never: String(b.never || '').split(','), public: b.public === '1' });
++ res.redirect(303, `/write/voice?saved=${encodeURIComponent('Your card is saved.')}`);
++ }));
++
router.get('/posts/:id/revisions', wrap(async (req, res) => {
const found = await postFor(req);
if (!found) return notFound(req, res);
ddiff --git a/server/patch-notes/draft.js b/server/patch-notes/draft.js
nnew file mode 100644
iindex 0000000..0f2e0b5
---- /dev/null
++++ b/server/patch-notes/draft.js
@@@ -0,0 +1,289 @@
++'use strict';
++/**
++ * Patch notes v2, step 7 and §3.1: the post template, written from the story map.
++ *
++ * compose() writes the deterministic draft: title (5–9 words, no counts), dek (one sentence for the feed), the
++ * short version (three player outcomes), one H2 per top workstream with what changed, why and how to see it,
++ * Under the hood for chores, What's next from open PRs (at most three, each linked), and the full list in a
++ * collapsible block — the only place commit subjects appear. When OpenVibe.AI is configured, the same material
++ * goes to blog.draft_post as numbered sources plus the voice card and the two newest published posts, and the
++ * answer replaces the story prose; the tail (list, next, sign-off) stays deterministic. Either way the result is
++ * a DRAFT: this module never publishes.
++ */
++const { siteLabel } = require('./text');
++const { sitesSentence, words } = require('./stories');
++
++const AUDIT = { homes: 42, targets: 2386, under: 2348, sitesUnder: 40, shared: 1628, source: 'research-2026-10-29/tap-targets.md' };
++
++const SIGNAL = {
++ 'sign-in': 'Sign-in lands',
++ touch: 'taps grow',
++ 'phone-home': 'homes fit a phone',
++ orbit: 'the orbit stays put',
++ comfort: 'comfort follows you',
++ mcp: 'the design system opens up',
++};
++// The order a post leads with: the account flow first, then the phone work. Rank still picks the top stories.
++const LEAD = { 'sign-in': 0, touch: 1, 'phone-home': 2, orbit: 3, comfort: 4, mcp: 5 };
++const forPost = (stories) => [...stories].sort((a, b) => (LEAD[baseKey(a.key)] == null ? 9 : LEAD[baseKey(a.key)]) - (LEAD[baseKey(b.key)] == null ? 9 : LEAD[baseKey(b.key)]));
++const cap = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1);
++
++const lowerFirst = (s) => (s ? s.charAt(0).toLowerCase() + s.slice(1) : s);
++const sentence = (s) => String(s || '').replace(/\s+/g, ' ').trim();
++const baseKey = (key) => String(key || '').split('#')[0];
++
++function titleFor(stories) {
++ const signals = forPost(stories).slice(0, 3).map((s) => SIGNAL[baseKey(s.key)]).filter(Boolean);
++ if (signals.length >= 3) return cap(`${signals[0]}, ${signals[1]}, and ${signals[2]}`);
++ if (signals.length === 2) return cap(`${signals[0]} and ${signals[1]}`);
++ if (signals.length === 1) return cap(signals[0]);
++ return stories[0] ? stories[0].title : 'What changed this round';
++}
++
++function dekFor(stories) {
++ const top = forPost(stories).slice(0, 2);
++ const parts = top.map((s) => {
++ const sites = s.sites.length;
++ switch (baseKey(s.key)) {
++ case 'sign-in': return `${words(sites)} more ${sites === 1 ? 'site signs' : 'sites sign'} in through one module`;
++ case 'touch': return `${words(sites)} more keep their targets at 44px on a phone for a thumb`;
++ case 'phone-home': return `${words(sites)} home ${sites === 1 ? 'page no longer runs' : 'pages no longer run'} wider than the screen`;
++ case 'orbit': return 'the orbit stays where it is';
++ default: return lowerFirst(s.title);
++ }
++ });
++ const text = parts.length === 2 ? `${parts[0]}, and ${parts[1]}` : parts[0] || 'The changes this round, with the numbers and the links.';
++ return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
++}
++
++function introFor(stories) {
++ const names = forPost(stories).slice(0, 3).map((s) => (baseKey(s.key) === 'sign-in' ? 'sign-in' : baseKey(s.key) === 'phone-home' ? 'phone screens' : baseKey(s.key) === 'touch' ? 'thumbs' : lowerFirst(s.title)));
++ const list = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` : names[0];
++ return `This round is ${list}. The short version, then the details.`;
++}
++
++function shortVersionFor(stories) {
++ return forPost(stories).slice(0, 3).map((s) => {
++ const sites = s.sites.length;
++ switch (baseKey(s.key)) {
++ case 'sign-in': return `Sign-in is one module on ${words(sites)} more ${sites === 1 ? 'site' : 'sites'}. A lost flow retries once by itself; if it still cannot, you get a Try again page.`;
++ case 'touch': return `${words(sites).replace(/^./, (c) => c.toUpperCase())} more sites keep their targets at 44px on a phone: chips, FAQ rows, and the small links.`;
++ case 'phone-home': return `${words(sites).replace(/^./, (c) => c.toUpperCase())} home ${sites === 1 ? 'page no longer runs' : 'pages no longer run'} wider than the phone screen.`;
++ case 'orbit': return 'The orbit stays in place, and its labels stay clear of each other.';
++ default: return `${s.title.replace(/\.$/, '')}.`;
++ }
++ });
++}
++
++function movedAndPinned(entries) {
++ const pinned = entries.filter((e) => /^pin\b/i.test(String(e.subject || '').trim()));
++ const moved = entries.length - pinned.length;
++ return { moved, pinned: pinned.length };
++}
++
++/** The first sentences of a commit body that carry the reason or a measurement. */
++function factsOf(entry, max = 2) {
++ const lines = String(entry.body || entry.message_body || '')
++ .split('\n')
++ .map((l) => l.trim())
++ .filter((l) => l && !/^[-*]\s/.test(l) && !/^(what this buys|the site's own copy|move the site's own copy)/i.test(l) && l.length > 40);
++ return lines.slice(0, max).map(sentence);
++}
++
++function sectionFor(story, ctx) {
++ const key = baseKey(story.key);
++ const sites = sitesSentence(story.entries);
++ const n = story.sites.length;
++ if (key === 'sign-in') {
++ const { moved, pinned } = movedAndPinned(story.entries);
++ const how = [];
++ if (moved) how.push(`${words(moved)} moved their copy onto the module`);
++ if (pinned) how.push(`${words(pinned)} moved the module in without touching their code`);
++ return [
++ `Every site used to carry its own copy of the sign-in code. That meant one place to fix for each site when a flow expired, a secret was wrong, or a cookie changed. ${cap(how.join('; '))}: ${sites}.`,
++ 'A lost or expired flow, or a code the Network no longer accepts, starts the sign-in once more by itself. If it still cannot, you get a page with a Try again button instead of a dead end. A client the Network refuses is logged as a configuration error, and the site\'s health check answers 503, so a bad setup fails at deploy, not at your first visit.',
++ 'The old site cookie is read as the session, so moving the code did not sign anyone out.',
++ `To see it, sign out and back in on any of these: ${sites}.`,
++ ];
++ }
++ if (key === 'touch') {
++ return [
++ `The audit measures ${AUDIT.homes} product home pages at 390px: ${AUDIT.targets.toLocaleString('en-US')} targets, ${AUDIT.under.toLocaleString('en-US')} of them under 44px on ${AUDIT.sitesUnder} of the ${AUDIT.homes}. This round is the per-site groups on ${words(n)} more: ${sites}.`,
++ 'The text can stay small. The target cannot. On a phone, a chip, a FAQ row or a small link now takes a 44px touch when the word is narrower than that.',
++ `The shared navbar and footer are the next pass; they accounted for ${AUDIT.shared.toLocaleString('en-US')} of the audit's targets.`,
++ `To see it, open one of these on a phone and tap the smallest link: ${sites}.`,
++ ];
++ }
++ if (key === 'phone-home') {
++ return [
++ `At 390px, ${words(n)} home ${n === 1 ? 'page was' : 'pages were'} wider than the screen. The right edge was cut off, or the page zoomed out to fit it. ${sites} now stay inside the width.`,
++ 'Each one fixed the page\'s width rule; the cards keep their size.',
++ `To see it, open the home page at 390px and drag sideways: the page itself does not move.`,
++ ];
++ }
++ if (key === 'orbit') {
++ const facts = [...new Set(story.entries.flatMap((e) => factsOf(e)))].slice(0, 4);
++ return [
++ 'The touch-target pass added `position: relative` to `.orb-node` on touch screens. The orbit places each node with `position: absolute`, so the link went inline, lost its 34px size, and every icon filled the orbit. On a phone the page was 857px wide.',
++ 'The fix keeps the absolute placement; the `::after` hit area already works. On phones the orbit grows to 340px, the inner ring is 27.5% of the card width, the outer ring is 46.5% with 26px icons, and the core mark is 52px. Labels no longer cross the core or the outer sites at 270px. Under 360px, the core drops its "one account" line.',
++ ...(facts.length ? [facts.join(' ')] : []),
++ 'To see it, open the Network home on a phone: the rings turn, and the page does not grow.',
++ ];
++ }
++ const facts = [...new Set(story.entries.flatMap((e) => factsOf(e, 1)))].slice(0, 3);
++ return [
++ `${story.title} landed on ${sites}.`,
++ ...(facts.length ? facts : ['The change is live on those sites now.']),
++ `To see it, open ${sites}.`,
++ ];
++}
++
++function underTheHood(chores) {
++ if (!chores.length) return '';
++ const versions = new Map();
++ let docs = 0;
++ for (const e of chores) {
++ const subject = String(e.subject || '');
++ const v = subject.match(/openvibe-(shared|sdk|contracts|publishing)\s+(v\d+\.\d+\.\d+)/i);
++ if (/in the docs|documentation|readme/i.test(subject)) { docs++; continue; }
++ if (v) {
++ const key = `openvibe-${v[1].toLowerCase()} ${v[2].toLowerCase()}`;
++ versions.set(key, (versions.get(key) || 0) + 1);
++ }
++ }
++ const parts = [...versions.entries()].map(([v, n]) => `${v} on ${n} ${n === 1 ? 'site' : 'sites'}`);
++ if (docs) parts.push(`the sign-in SDK's docs on ${docs} ${docs === 1 ? 'site' : 'sites'}`);
++ const what = parts.length ? parts.join(', ') : `${chores.length} housekeeping ${chores.length === 1 ? 'commit' : 'commits'}`;
++ return `## Under the hood\n\nThe batch also carried ${what}. Lockfiles, version strings and docs only: no new user-facing change, so they get this paragraph instead of a heading each.\n`;
++}
++
++function labelForPull(p) {
++ const m = String(p.title || '').match(/^([A-Za-z][A-Za-z .-]{1,24}):\s+(.+)$/);
++ if (m) return { label: m[1].trim(), text: m[2].trim() };
++ if (/sign.?in|sso/i.test(p.title)) return { label: 'Sign-in', text: String(p.title) };
++ if (/phone|touch|drag|finger/i.test(p.title)) return { label: 'Phones', text: String(p.title) };
++ return { label: 'Next', text: String(p.title) };
++}
++
++/** The open PRs worth naming: user-facing work, never a version bump, at most `max`. */
++function nextItems(pulls = [], max = 3) {
++ const pin = /^(?:openvibe-(?:shared|sdk|contracts|publishing)\s+v?\d|pin\b|bump\b)|openvibe-(?:shared|sdk|contracts|publishing)\s+v?\d+(?:\.\d+)*\s*\((?:was|from)\b/i;
++ const seen = new Set();
++ return pulls
++ .filter((p) => p && p.url && /^https:\/\/github\.com\/OpenVibers\/[^/]+\/pull\/\d+$/.test(p.url))
++ .filter((p) => !pin.test(String(p.title || '')))
++ .filter((p) => { const k = String(p.title || '').toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; })
++ .slice(0, max);
++}
++
++function whatsNext(pulls) {
++ const items = nextItems(pulls);
++ if (!items.length) return '';
++ const lines = items.map((p) => {
++ const { label, text } = labelForPull(p);
++ const cleanText = text.replace(/^v?\d+\.\d+\.\d+:\s*/, '').replace(/\.$/, '');
++ return `- **${label}, open PR:** ${cleanText} ([#${p.number}](${p.url})).`;
++ });
++ return `## What's next\n\n${lines.join('\n')}\n`;
++}
++
++function fullList(map, stories) {
++ const link = (e) => `- [${sentence(e.subject)}](${e.url || `https://github.com/${e.repo}/commit/${e.sha}`})`;
++ const bySite = (list) => [...list].sort((a, b) => siteLabel(a).localeCompare(siteLabel(b)) || String(a.subject).localeCompare(String(b.subject)));
++ const out = ['## The full list', '', '<details>', '<summary>The commits behind these stories</summary>', ''];
++ for (const s of stories) {
++ out.push(`**${s.title}**`);
++ out.push(...bySite(s.entries).map(link));
++ out.push('');
++ }
++ if (map.also.length) {
++ out.push('**Also shipped**');
++ out.push(...bySite(map.also).map(link));
++ out.push('');
++ }
++ if (map.internal.length) {
++ out.push('**Internal**');
++ out.push(...bySite(map.internal).map(link));
++ out.push('');
++ }
++ if (map.chores.length) {
++ out.push('**Under the hood**');
++ out.push(...bySite(map.chores).map(link));
++ out.push('');
++ }
++ out.push('</details>');
++ return out.join('\n');
++}
++
++/** The whole post, deterministically. */
++function compose({ map, voice, pulls = [] }) {
++ const stories = map.stories;
++ const signoff = (voice && voice.card && voice.card.signoff) || '— the OpenVibe crew';
++ if (!stories.length) {
++ const body = [underTheHood(map.chores), fullList(map, []), '', signoff].filter((x) => x != null).join('\n');
++ return {
++ title: 'The package versions moved', dek: 'Housekeeping only this round: package versions, lockfiles and docs, with no new user-facing change.',
++ body: body.replace(/\n{3,}/g, '\n\n').trim(), tags: ['patch-notes'],
++ };
++ }
++ const title = titleFor(stories);
++ const dek = dekFor(stories);
++ const body = [
++ introFor(stories), '',
++ '## The short version', '',
++ ...shortVersionFor(stories).map((b) => `- ${b}`), '',
++ ...forPost(stories).flatMap((s) => [`## ${s.title}`, '', ...sectionFor(s, {}), '']),
++ underTheHood(map.chores),
++ whatsNext(pulls),
++ fullList(map, stories),
++ '',
++ (voice && voice.card && voice.card.signoff) || '— the OpenVibe crew',
++ ].filter((line) => line !== null && line !== undefined).join('\n');
++ return { title, dek, body: body.replace(/\n{3,}/g, '\n\n').trim(), tags: ['patch-notes'] };
++}
++
++/** The brief the AI gets: the template, the voice card and the story map (4,000 chars is the workflow's limit). */
++function briefFor({ map, voice, blog }) {
++ const storyLines = map.stories.map((s, i) => {
++ const facts = [...new Set(s.entries.flatMap((e) => factsOf(e, 1)))].slice(0, 2).join(' ');
++ return `${i + 1}. ${s.title} (${s.kind}); ${s.sites.length} sites: ${sitesSentence(s.entries)}.${facts ? ` Evidence: ${facts.slice(0, 220)}` : ''}`;
++ });
++ const text = [
++ `Draft the Patch notes post for @${blog.handle}, in the voice card below. The numbered sources are the commits (source_type git.commit) and two recent posts in this voice (blog.post.sample). Use only them. Put anything you cannot support in gaps; invent nothing.`,
++ '',
++ 'Template, exactly:',
++ '- title: 5–9 words, the promise of this round. No counts, no versions, no "Patch notes:" prefix.',
++ '- dek: one sentence, 20–35 words, the top two changes and who they are for.',
++ '- body: a two-sentence intro; "## The short version" with 3 bullets, each a player outcome that stands alone; then one "## <what changed>" per workstream below, 2–6 sections, each saying what changed, why, and how to see it. No repository names, no commit subjects, no PR numbers, no "workstream", "pin", "fleet audit" or "shipped" as a noun in the story.',
++ '- Do not write "Under the hood", "What\'s next", "Thanks" or "The full list": they are added after your text. Do not sign off.',
++ '- No adjectives about the work. A number, a before/after, or what the reader can do now. One dry joke at most, never in the first paragraph.',
++ '',
++ 'Workstreams this round:',
++ ...storyLines,
++ ...(map.chores.length ? [`Housekeeping (not a section; one line is enough if you mention it): ${sitesSentence(map.chores) || `${map.chores.length} commits`}.`] : []),
++ '',
++ voice.text ? `Voice card:\n${voice.text}` : '',
++ ].join('\n');
++ return text.slice(0, 4000);
++}
++
++/** Merge the AI's story prose with the deterministic tail; the full list is never the model's job. */
++function mergeAi(aiBody, det, voice) {
++ let body = String(aiBody || '').replace(/\r\n/g, '\n').trim();
++ if (!body) return det.body;
++ body = body.replace(/^#\s+.+\n+/, '');
++ const cut = body.search(/^##\s+(Under the hood|What's next|Thanks|The full list)\s*$/im);
++ if (cut >= 0) body = body.slice(0, cut).trim();
++ body = body.replace(/^—\s*the OpenVibe crew\s*$/im, '').replace(/\n*—\s*the OpenVibe crew\s*$/i, '').trim();
++ if (!/^##\s+The short version/im.test(body)) {
++ body = `${body}\n\n${det.body.match(/## The short version[\s\S]*?(?=\n##\s)/)[0].trim()}`;
++ }
++ if (body.trim().startsWith('##')) {
++ const intro = det.body.split('\n\n')[0];
++ body = `${intro}\n\n${body}`;
++ }
++ const tail = det.body.slice(det.body.indexOf('## Under the hood') >= 0 ? det.body.indexOf('## Under the hood') : det.body.indexOf("## What's next") >= 0 ? det.body.indexOf("## What's next") : det.body.indexOf('## The full list'));
++ return `${body}\n\n${tail}`.replace(/\n{3,}/g, '\n\n').trim();
++}
++
++module.exports = { compose, briefFor, mergeAi, titleFor, dekFor, sectionFor, nextItems, AUDIT };
ddiff --git a/server/patch-notes/index.js b/server/patch-notes/index.js
nnew file mode 100644
iindex 0000000..9498a0d
---- /dev/null
++++ b/server/patch-notes/index.js
@@@ -0,0 +1,205 @@
++'use strict';
++/**
++ * Patch notes v2 (devblog §3). One module owns the pipeline:
++ *
++ * collect (server/changelog.js) → enrich (commit body, files, PR) → cluster and rank (stories.js)
++ * → draft (draft.js: the template, with OpenVibe.AI's story prose when it is configured)
++ * → check (review.js: slop, looks, links, alt, review) → a DRAFT for a person; never a publish.
++ *
++ * The draft is a post in the "Patch notes" series, authored as AI-generated (workflow blog.draft_post) when the
++ * AI wrote the prose, else imported from the repositories; in both cases it stays a draft and noindex, and a
++ * blocking check or a missing person's approval stops posts.publish.
++ */
++const { build } = require('./stories');
++const { compose, briefFor, mergeAi } = require('./draft');
++const review = require('./review');
++const { createVoiceCards } = require('./voice');
++
++const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
++const asJson = (v) => (v && typeof v === 'object' ? JSON.stringify(v) : v);
++
++function createPatchNotes({ config, store, blogs, posts, publication, aiDrafts = null, voiceCards = null, changelog = null, fetchImpl = globalThis.fetch, log = console }) {
++ const db = store.db;
++ const voice = voiceCards || createVoiceCards({ store });
++ const maxStories = Math.max(2, Math.min(8, (config.patchNotes && config.patchNotes.maxStories) || 6));
++ const sampleCount = Math.max(0, Math.min(4, (config.patchNotes && config.patchNotes.samples) || 2));
++ const q = {
++ get: db.prepare('SELECT * FROM blog_patch_note_drafts WHERE post_id = ?'),
++ put: db.prepare(`INSERT INTO blog_patch_note_drafts (post_id, blog_id, stories, entries, checks, voice_card, override, created_at, updated_at)
++ VALUES (@post_id, @blog_id, @stories, @entries, @checks, @voice_card, @override, @now, @now)
++ ON CONFLICT (post_id) DO UPDATE SET stories = excluded.stories, entries = excluded.entries, checks = excluded.checks,
++ voice_card = excluded.voice_card, override = COALESCE(excluded.override, blog_patch_note_drafts.override), updated_at = excluded.updated_at`),
++ checks: db.prepare('UPDATE blog_patch_note_drafts SET checks = ?, updated_at = ? WHERE post_id = ?'),
++ override: db.prepare('UPDATE blog_patch_note_drafts SET override = ?, updated_at = ? WHERE post_id = ?'),
++ };
++ const parse = (v) => (v == null ? null : typeof v === 'string' ? JSON.parse(v) : v);
++
++ function attach({ changelog: cl }) { changelog = cl; return api; }
++
++ const ownerSubject = () => (config.official.owners || []).find((s) => /^usr_/.test(s)) || null;
++
++ /** The two newest published posts of the blog, cut to a source's size: the voice samples in the prompt. */
++ async function samplesOf(blog) {
++ if (!sampleCount || !posts.listPublished) return [];
++ const { posts: rows } = await posts.listPublished({ blogId: blog.id, limit: sampleCount, order: 'recent' });
++ const out = [];
++ for (const p of rows) {
++ const rev = p.published_revision ? await posts.revision(p, p.published_revision) : null;
++ if (!rev) continue;
++ out.push({ title: rev.fields.title, dek: rev.fields.summary || '', body: String(rev.content || '').slice(0, 3000), url: publication.postUrl(blog, p) });
++ }
++ return out;
++ }
++
++ const entryView = (e) => ({ service: e.service, repo: e.repo, sha: e.sha, subject: e.subject, url: e.url || `https://github.com/${e.repo}/commit/${e.sha}`, deployed_at: e.deployed_at });
++
++ /** The prompt's numbered sources: the commits (with their bodies) and the voice samples. */
++ function sourcesFor(entries, samples) {
++ const out = [];
++ for (const e of entries) {
++ out.push({
++ source_type: 'git.commit', source_id: String(e.sha || ''), url: String(e.url || `https://github.com/${e.repo}/commit/${e.sha}`).slice(0, 2048),
++ title: clean(e.subject, 500), author: clean(e.author, 200) || undefined,
++ published_at: e.committed_at || e.deployed_at || undefined,
++ content: String(e.body || e.message_body || e.subject || '').slice(0, 20000),
++ });
++ }
++ for (const s of samples) {
++ out.push({ source_type: 'blog.post.sample', url: String(s.url || '').slice(0, 2048), title: clean(s.title, 500), snippet: String(s.body || '').slice(0, 4000) });
++ }
++ return out.filter((s) => s.source_type !== 'blog.post.sample' || s.url);
++ }
++
++ /**
++ * The whole draft without writing it: { doc, map, checks, card, samples, usedAi }.
++ * `useAi: false` always gives the deterministic post (the CLI's --no-ai, and tests).
++ */
++ async function plan({ entries, blog, pullRequests = null, account = 'official', useAi = true, traceparent = null }) {
++ const owner = ownerSubject();
++ if (!owner) throw Object.assign(new Error('BLOG_OFFICIAL_OWNERS names no usr_ owner to file the draft as'), { code: 'patch_notes.author_required' });
++ const list = (entries || []).map((e) => ({ ...e }));
++ if (!list.length) return null;
++ const map = build(list, { maxStories });
++ const card = await voice.get(account);
++ const samples = await samplesOf(blog);
++ const pulls = pullRequests || (changelog && changelog.openPulls ? await changelog.openPulls().catch(() => []) : []);
++ const det = compose({ map, voice: card, pulls });
++ let usedAi = false;
++ let ai = null;
++ if (useAi && aiDrafts && aiDrafts.enabled && aiDrafts.rawDraft) {
++ try {
++ ai = await aiDrafts.rawDraft({ kind: 'user', subject: owner, staff: false }, blog, {
++ topic: det.title, brief: briefFor({ map, voice: card, blog }), tone: 'specific, dry, plain, first person plural',
++ audience: 'OpenVibe members and contributors, many on a phone', sources: sourcesFor(list, samples),
++ }, { traceparent });
++ if (ai && ai.output && String(ai.output.body_markdown || '').trim()) {
++ det.body = mergeAi(ai.output.body_markdown, det, card);
++ det.title = clean(ai.output.title, 200) || det.title;
++ det.dek = clean(ai.output.dek, 400) || det.dek;
++ if (Array.isArray(ai.output.tags)) det.tags = ['patch-notes', ...ai.output.tags.map((t) => clean(t, 50)).filter(Boolean)].slice(0, 8);
++ usedAi = true;
++ }
++ } catch (err) {
++ log.warn && log.warn(`[patch-notes] AI prose not used: ${err.message}`);
++ }
++ }
++ const authorship = usedAi
++ ? { mode: 'ai', workflow: ai.workflow, stubProvider: Boolean(ai.synthetic), source: { label: 'Commit messages from the OpenVibers repositories on GitHub' } }
++ : { mode: 'imported', importedFrom: { label: 'Commit messages from the OpenVibers repositories on GitHub', originalAuthor: 'OpenVibers' } };
++ const checks = await review.evaluate({
++ title: det.title, dek: det.dek, body: det.body, tags: det.tags, attachments: [], entries: list,
++ storyMap: map.stories.map((s) => ({ key: s.key, title: s.title, kind: s.kind, sites: s.sites, commits: s.entries.length })),
++ review: null, authorship, override: null,
++ });
++ return { doc: det, map, card, samples, usedAi, ai, authorship, checks, owner, entries: list };
++ }
++
++ /** Write the draft (never publish). Returns { created, post, url, checks, doc }. */
++ async function draftPending({ entries = null, pullRequests = null, reason = 'manual', account = 'official', useAi = true, traceparent = null } = {}) {
++ const blog = await blogs.byHandle(config.changelog.blogHandle) || await blogs.ensureOfficial();
++ let list = entries;
++ if (!list) list = changelog ? await changelog.pendingEntries() : [];
++ if (changelog && changelog.enrich && list.length && list.some((e) => e.id != null)) {
++ try { list = await changelog.enrich(list); } catch (err) { log.warn && log.warn(`[patch-notes] enrichment skipped: ${err.message}`); }
++ }
++ if (!list.length) return { created: false, reason: 'nothing pending' };
++ const p = await plan({ entries: list, blog, pullRequests, account, useAi, traceparent });
++ if (!p) return { created: false, reason: 'nothing pending' };
++ const viewer = p.usedAi
++ ? { kind: 'service', service: 'svc:patch-notes', origin: 'ai', subject: p.owner }
++ : { kind: 'service', service: 'svc:patch-notes', origin: 'patch-notes', subject: p.owner };
++ const citations = p.usedAi && p.ai ? (p.ai.citations || []).filter((c) => c && (c.url || c.title)).map((c) => ({
++ url: c.url || null, title: c.title || null, retrievedAt: c.retrieved_at || null, quote: c.snippet ? { text: String(c.snippet).slice(0, 1000) } : null,
++ })) : undefined;
++ const { post, revision } = await posts.create(viewer, blog, {
++ title: p.doc.title, summary: p.doc.dek, body: p.doc.body, tags: p.doc.tags, series: 'Patch notes',
++ authorship: p.authorship, citations, message: `Patch notes draft from ${list.length} commits (${reason})`,
++ }, { traceparent });
++ await q.put.run({
++ post_id: post.id, blog_id: blog.id, stories: asJson(p.map.stories.map((s) => ({ key: s.key, title: s.title, kind: s.kind, sites: s.sites, commits: s.entries.map((e) => e.sha) }))),
++ entries: asJson(list.map(entryView)), checks: asJson(p.checks), voice_card: asJson(p.card.card), override: null, now: store.now(),
++ });
++ if (changelog && changelog.markEntries) await changelog.markEntries(list, post.id);
++ log.log && log.log(`[patch-notes] drafted ${post.id} from ${list.length} commits${p.usedAi ? ' (AI prose)' : ''}`);
++ return { created: true, post, revision, url: publication.postUrl(blog, post), doc: p.doc, checks: p.checks, usedAi: p.usedAi, stories: p.map.stories };
++ }
++
++ async function load(postId) {
++ const row = await q.get.get(String(postId));
++ if (!row) return null;
++ return { ...row, stories: parse(row.stories) || [], entries: parse(row.entries) || [], checks: parse(row.checks), voice_card: parse(row.voice_card), override: parse(row.override) };
++ }
++
++ /** Run the checks on the head revision and store them for the reviewer. */
++ async function check(postId, { verifyLinks = null } = {}) {
++ const row = await load(postId);
++ if (!row) return null;
++ const post = await posts.get(postId);
++ if (!post) return null;
++ const rev = await posts.head(post);
++ const rec = rev ? publication.authorshipOf(rev) : null;
++ const revReview = await publication.reviewOf(post, rev);
++ const attachments = await posts.attachments(post);
++ const result = await review.evaluate({
++ title: rev.fields.title, dek: rev.fields.summary || '', body: rev.content, tags: (await publication.termsOf(post)).tags || [],
++ captions: attachments.map((a) => a.caption).filter(Boolean), attachments,
++ entries: row.entries, storyMap: row.stories, review: revReview, authorship: rec, override: row.override, verifyLinks,
++ });
++ await q.checks.run(asJson(result), store.now(), postId);
++ return { row, post, rev, result, review: revReview };
++ }
++
++ /** The publish gate for posts.applyPublish: null for anything that is not a Patch notes draft. */
++ async function gate(post, rev) {
++ const row = await load(post.id);
++ if (!row) return null;
++ const rec = rev ? publication.authorshipOf(rev) : null;
++ const revReview = await publication.reviewOf(post, rev);
++ const attachments = await posts.attachments(post);
++ const result = await review.evaluate({
++ title: rev.fields.title, dek: rev.fields.summary || '', body: rev.content, tags: (await publication.termsOf(post)).tags || [],
++ captions: attachments.map((a) => a.caption).filter(Boolean), attachments,
++ entries: row.entries, storyMap: row.stories, review: revReview, authorship: rec, override: row.override,
++ });
++ await q.checks.run(asJson(result), store.now(), post.id);
++ return result;
++ }
++
++ async function setOverride(postId, { reason, phrases = [], by = null } = {}) {
++ const row = await load(postId);
++ if (!row) return null;
++ const text = clean(reason, 500);
++ if (!text) throw Object.assign(new Error('An override needs a reason the reviewer can read'), { status: 422, code: 'patch_notes.override_reason' });
++ const override = { reason: text, phrases: phrases.map((p) => String(p).toLowerCase()).slice(0, 20), by, at: store.now() };
++ await q.override.run(asJson(override), store.now(), postId);
++ return override;
++ }
++
++ async function storyMap(postId) { const row = await load(postId); return row ? row.stories : []; }
++ async function isPatchNotes(postId) { return Boolean(await q.get.get(String(postId))); }
++
++ const api = { plan, draftPending, check, gate, load, setOverride, storyMap, isPatchNotes, attach, voice, ownerSubject, samplesOf };
++ return api;
++}
++
++module.exports = { createPatchNotes };
ddiff --git a/server/patch-notes/no-looks.js b/server/patch-notes/no-looks.js
nnew file mode 100644
iindex 0000000..46e05ed
---- /dev/null
++++ b/server/patch-notes/no-looks.js
@@@ -0,0 +1,165 @@
++'use strict';
++/**
++ * Blog's copy of the owner's looks rule: public AI text never describes a person — no looks, body, clothing, hair,
++ * face, pose, age or guessed gender. Prompts say so; models still do it, so public AI text passes noLooks(). The
++ * rules and the test corpus are openvibe-sdk/no-looks (OpenVibe.Live, Live#298–#302, moved into the SDK); Blog pins
++ * an older openvibe-sdk that does not carry the module yet, so this is the same implementation, unchanged, and the
++ * next SDK pin replaces it with a require of openvibe-sdk/no-looks.
++ *
++ * - noLooks(text): "A shirtless man sits…" → "The streamer sits…"; "a man in a kitchen" → "a person in a kitchen";
++ * "two men" → "two people"; a clause about clothes, hair or a pose goes.
++ * - noLooksTags(tags): drops tags that name clothing, hair, face, age or a person by gender.
++ * - hasLooks(text): true when a short text names looks at all (a title is replaced, not repaired).
++ * - isExplicit(...texts): a scene description that says sexual content (never published at all).
++ * - RULES_VERSION: a stable hash of the rules.
++ */
++
++
++const _LOOKS = 'shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed|young|younger|teenage|teen|little|bearded|bald|blonde?|(?:dark|long|short|curly|red|gr[ae]y|black|brown)-haired|tattooed|muscular|heavyset|older|elderly|middle-aged|skinny|slim|chubby|attractive|pretty|handsome|cute';
++const _PEOPLE = 'man|woman|boy|girl|guy|gal|kid|child|teen|teenager|lady|gentleman|male|female|person|individual|streamer|youth';
++const _AGE_NOUN = /^(?:boy|girl|kid|child|teen|teenager|youth)$/i;
++const _LOOKED_AT = new RegExp(`\\b(a|an|the|one)\\s+((?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)*)(${_PEOPLE})\\b`, 'gi');
++const _LOOKED_AT_BARE = new RegExp(`\\b(?:(?:${_LOOKS})[\\s,]+(?:and\\s+)?)+(${_PEOPLE})\\b`, 'gi');
++const _cap = (m, s) => (/^[A-Z]/.test(m) ? s[0].toUpperCase() + s.slice(1) : s);
++// What someone wears or how their hair and face look ("wearing a black beanie and hoodie", "in a red shirt",
++// "with blond hair and a mustache", "a headset-wearing host") goes; the sentence keeps everything else. Quoted
++// speech ("Heard: "I like your hair"") is what was said and stays word for word.
++const _ART = '(?:(?:a|an|the|his|her|their|some|matching)\\s+)?';
++const _WORN = 't-shirts?|shirts?|tees?|tank tops?|hoodies?|sweatshirts?|sweaters?|jackets?|coats?|vests?|beanies?|baseball caps?|caps?|hats?|helmets?|headsets?|headphones|sunglasses|glasses|outfits?|dress(?:es)?|jerseys?|suits?|robes?|bandanas?|scarf|scarves|masks?|apparel|attire';
++const _FACE = 'beards?|mustaches?|moustaches?|goatees?|stubble|sunglasses|glasses|tattoos?|hair|ponytails?|braids|dreadlocks|piercings?|freckles|makeup|expressions?|beanies?|backward caps?|baseball caps?|hoodies?';
++const _WORN_ANY = `${_WORN}|microphones?|mics?|earbuds?|earphones?|belts?|gloves|boots|shoes|sneakers|jeans|pants|shorts|necklaces?|chains?|earrings|watch(?:es)?|uniforms?|clothes|clothing|gear|costumes?|wigs?|crowns?|goggles|visors?|lanyards?|tops?|neckties?|bow ties?|ties?|headbands?|garments?|turbans?`;
++const _WORN_NP = `${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY})\\b(?:\\s+(?:on|around|over|across)\\s+(?:his|her|their|the|its)\\s+(?:head|neck|face|forehead|shoulders?|ears?|eyes|chest))?(?:\\s+with\\s+(?:(?:a|an)\\s+)?(?:[\\w'-]+\\s+){0,2}?(?:zippers?|logos?|prints?|patterns?|designs?|graphics?|hoods?|stripes?|collars?|text|eyes|ears|horns|patch(?:es)?|badges?|stickers?|emblems?|lettering|writing|trim|buttons|accents|embellishments|details|sleeves))?`;
++const _WEARING = new RegExp(`,?\\s*(?:who\\s+(?:is\\s+)?|while\\s+|and\\s+)?(?<!-)\\b(?:wearing|wears|dressed in|sporting)\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*`, 'gi');
++// Clothes below the waist say what someone wears as much ("The streamer in shorts kneels on grass…", a Community
++// summary from 2026-04 that every repair left), but "in the pants department" is a place: a shop word after it keeps it.
++const _LOWER = 'shorts|jeans|pants|trousers|leggings|skirts?|sweatpants|joggers|pajamas|pyjamas|swimsuits?|swimwear|bikinis?|underwear|lingerie|overalls|tights|dungarees';
++const _IN_WORN = new RegExp(`\\s+in\\s+(?:(?:a|an|the|his|her|their)\\s+)?(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN}|(?:${_LOWER})(?!\\s+(?:department|store|shop|section|aisle|pockets?|drawer)))\\b(?:\\s+(?:and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY})\\b)*`, 'gi');
++const _PERSON_WITH_WORN = new RegExp(`\\b((?:${_PEOPLE}|broadcaster|host|guy|gamer|player|viewer|someone|people|men|women|speaker|presenter)\\s+)with\\s+${_ART}(?:[\\w'-]+\\s+){0,3}?\\b(?:${_WORN_ANY}|${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_WORN_ANY}|${_FACE})\\b)*\\s*`, 'gi');
++const _WITH_FACE = new RegExp(`\\s+with\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE})\\b(?:\\s*(?:,|and|or)\\s+${_ART}(?:[\\w'-]+\\s+){0,2}?\\b(?:${_FACE}|${_WORN})\\b)*`, 'gi');
++const _WEARS_AND = new RegExp(`(?<!-)\\bwears\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*\\s+and\\s+(?=\\w)`, 'gi');
++// "…, while the other wears a dusty t-shirt;" goes as a whole clause.
++const _WEARS_CLAUSE = new RegExp(`,?\\s+(?:while|and|as|whereas)\\s+(?:the other|one|another|someone|he|she|they|the streamer|(?:a|the) (?:man|woman|person|guy))\\s+wears\\s+${_WORN_NP}(?:\\s*(?:,|and|or|with|over|under)\\s+${_WORN_NP})*(?=\\s*(?:[;,.!?]|$))`, 'gi');
++// "a plaid/checkered shirt": a slashed pair of words before what is worn reads as one word, so the rules below see it.
++const _SLASHED_WORN = new RegExp(`\\b([\\w'-]+)/[\\w'-]+(?=\\s+(?:[\\w'-]+\\s+){0,2}?(?:${_WORN_ANY})\\b)`, 'gi');
++// ", with a blue jacket partially visible": a comma-led clause about clothes, whoever wears them, goes (clothes only:
++// "a desk with a headset" stays).
++const _WITH_CLOTHES = /,\s*with\s+(?:(?:a|an|the|their|his|her)\s+)?(?:[\w'-]+\s+){0,2}?(?:t-shirts?|shirts?|tees?|tank tops?|hoodies?|sweatshirts?|sweaters?|jackets?|coats?|vests?|outfits?|dress(?:es)?|jerseys?|suits?|robes?|outerwear)\b[^,.;!?]*(?:,(?=\s+(?:is|are|was|were)\s))?/gi;
++function _noClothing(text) {
++ return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part
++ .replace(_SLASHED_WORN, '$1')
++ .replace(_WITH_CLOTHES, '')
++ // A parenthetical clause taken out of the middle ("A third person, with a red cap, is shown") leaves no ",,".
++ .replace(/\s*,\s*,\s*/g, ' ')
++ .replace(/\b[\w-]+-wearing\s+/gi, '')
++ .replace(_WEARS_AND, '')
++ .replace(_WEARS_CLAUSE, '')
++ .replace(/\b(?:casually|smartly|neatly|warmly|brightly|formally|well)[\s-]+dressed\s+/gi, '')
++ .replace(_PERSON_WITH_WORN, '$1')
++ .replace(_WEARING, '')
++ .replace(_IN_WORN, '')
++ .replace(_WITH_FACE, '')
++ .replace(/\s+([,.;:!?])/g, '$1')
++ .replace(/,(?=[.;:!?])/g, '')
++ .replace(/([:;]),/g, '$1')
++ .replace(/ {2,}/g, ' '))).join('');
++}
++// Tags shown with public AI items ("red-beanie", "bearded", "man", "tanktop") follow the same rule: a tag naming
++// clothing, hair, face, age or a person by gender is dropped.
++const _LOOKS_TAG = new RegExp(`(?:^|[\\s_-])(?:${_WORN}|${_FACE}|${_LOOKS}|men|women|man|woman|boy|girl|guy|gal|kid|child|teen|teenager|lady|male|female|youth|shirtless|topless|tanktop|selfie)(?=$|[\\s_-])`, 'i');
++// A short AI title that names looks ("Orange Shirt Kitchen Chat") cannot be repaired word by word: it is not used.
++const _LOOKS_WORD = new RegExp(`\\b(?:${_WORN}|${_FACE}|${_LOOKS}|faces?|smil(?:e|ing)|body|skin|men|women|man|woman|boy|girl|guy|kid|child|teen|lady|male|female|shirtless|topless)\\b`, 'i');
++// Names are not people (as noLooks spares them): "Iron Man Speedrun", "Pac-Man Marathon", "Spider-Man 2" are titles
++// about a game or a character, so a hyphenated name and the few two-word ones are skipped before the word test.
++const _NAMED = /\b(?:iron|mega|pac|ant|spider|bat|he|ray)[\s-]+(?:man|men|woman|women)\b|-(?:man|men|woman|women|boy|girl)\b/gi;
++const _hasLooks = (t) => _LOOKS_WORD.test(String(t || '').replace(_NAMED, ' '));
++function _noLooksTags(tags) {
++ return (Array.isArray(tags) ? tags : []).filter((t) => typeof t === 'string' && t.trim() && !_LOOKS_TAG.test(t.trim()));
++}
++// Body language is the person too ("reacting with a thoughtful pose", "in a relaxed posture"): the phrase goes.
++const _POSE = /,?\s*(?:(?:appearing|seeming|seemingly)\s+(?:to\s+be\s+)?)?(?:(?:reacting|sitting|standing|leaning|posing|looking|resting)\s+)?(?:with|in)\s+(?:an?|their|his|her)\s+(?:[\w-]+\s+){0,2}(?:pose|posture|expression|facial expression|stance)\b/gi;
++// A guessed gender is a description of the person too (owner's rule: no looks, body, clothing, hair, age or guessed
++// gender): "a man in a kitchen" is "a person in a kitchen", "two men" are "two people", "a male or female host" is "a
++// host". Only after an article or a count, so names (Iron Man, Pac-Man, Spice Girls) and quoted speech stay.
++const _GENDERED = 'man|woman|guy|gal|lady|gentleman';
++const _GENDERED_ONE = new RegExp(`\\b(a|an|the|one|another|this|that|each|every)\\s+(?:${_GENDERED})\\b(?!-)`, 'gi');
++const _GENDERED_MANY = new RegExp(`\\b(two|three|four|five|several|some|the|both|many|other|few|these|those|\\d+)\\s+(?:men|women|guys|gals|ladies|gentlemen)\\b`, 'gi');
++// A pronoun is a guessed gender too: he and she are "the person", his and her (before a word) "their", him and her
++// (after a verb or a preposition, or at the end) "them", himself and herself "themself", hers "theirs". Outside
++// quotes only: what someone said stays word for word. "The person" keeps the verb as written ("he looks" → "the person
++// looks"), which "they" would not.
++const _HE_SHE = /\b(?:he|she)\b(?:'(s|d|ll)\b)?/gi;
++const _HER_OBJECT = /\bher\b(?=\s*(?:[.,;:!?)\]"”]|$)|\s+(?:and|or|but|to|from|with|in|on|at|for|as|while|when|because|so|if|the|a|an|this|that|is|was|again|back|up|down|out|off|away|over|now|too|here|there|more|about|into|onto)\b)/gi;
++const _PRONOUNS = [
++ [/\b(?:himself|herself)\b/gi, 'themself'],
++ [/\bhers\b/gi, 'theirs'],
++ [/\bhim\b/gi, 'them'],
++ [_HER_OBJECT, 'them'],
++ [/\b(?:his|her)\b(?=\s+[\w'-])/gi, 'their'],
++ [/\bhis\b/gi, 'theirs'],
++];
++const _CONTRACTED = { s: "'s", d: ' would', ll: ' will' };
++function _noPronouns(part) {
++ let out = part.replace(_HE_SHE, (m, c) => _cap(m, `the person${c ? _CONTRACTED[c.toLowerCase()] : ''}`));
++ for (const [re, word] of _PRONOUNS) out = out.replace(re, (m) => _cap(m, word));
++ return out;
++}
++const _GENDER_ADJ = /\b(?:(?:a|an)\s+)?(?:male|female)\s+(?:or|and|\/)\s+(?:male|female)\s+|\b(?:male|female)\s+(?=(?:hosts?|streamers?|players?|speakers?|presenters?|gamers?|voices?|narrators?|commentators?|figures?|characters?|broadcasters?|viewers?|individuals?|persons?|people)\b)/gi;
++// What is left of a sentence about someone's hair, clothes or body ("a person adjusts their hair or clothing near the
++// counter") cannot be repaired word by word: the sentence goes.
++const _GROOMING = new RegExp('\\b(?:(?:their|his|her)\\s+(?:own\\s+)?(?:hair|beard|face|makeup|clothing|clothes|outfit|appearance|body|figure|looks)'
++ // The face and body described ("smiling broadly, displaying white teeth and a beard") and clothes the word lists do
++ // not name ("blue outerwear", "in outdoor gear"): the sentence goes. Things a person holds or reviews (headphones, a
++ // headset on a desk) are not here.
++ + '|beards?|mustaches?|moustaches?|teeth|grin(?:s|ning)?|smil(?:e|es|ing)\\s+(?:broadly|widely|warmly|at)|freckles|complexion|wrinkles'
++ + '|hairstyles?|haircuts?|outerwear|(?:in|wearing)\\s+(?:[\\w-]+\\s+){0,2}gear|face\\s+shields?)\\b', 'i');
++function _noGender(text) {
++ return text.split(/("[^"]*"?|“[^”]*”?)/).map((part, i) => (i % 2 ? part : part
++ .replace(_GENDER_ADJ, (m) => (/^an?\s/i.test(m) ? m.slice(0, m.indexOf(' ') + 1) : ''))
++ .replace(_GENDERED_ONE, (m, art) => _cap(m, `${/^an$/i.test(art) ? 'a' : art.toLowerCase()} person`))
++ .replace(_GENDERED_MANY, (m, n) => _cap(m, `${n.toLowerCase()} people`))
++ .replace(/\ba\s+(?=[aeiou]\w*\s+(?:host|streamer|player|speaker|presenter|gamer|figure|character|broadcaster|person)\b)/gi, (m) => _cap(m, 'an '))
++ .replace(/ {2,}/g, ' '))).map((part, i) => (i % 2 ? part : _noPronouns(part))).join('');
++}
++function _noGrooming(text) {
++ const sentences = text.match(/[^.!?]+(?:[.!?]+(?:["”])?|$)\s*/g) || [text];
++ return sentences.filter((s) => !_GROOMING.test(s.replace(/"[^"]*"?|“[^”]*”?/g, ''))).join('').trim();
++}
++function _noLooks(text) {
++ if (!text || typeof text !== 'string') return text;
++ return _noGrooming(_noGender(_noLooksWords(text)));
++}
++function _noLooksWords(text) {
++ return _noClothing(text).replace(_POSE, '').replace(/\s+([,.;:!?])/g, '$1').replace(/ {2,}/g, ' ')
++ // Looks named: the person on camera. Only an age ("a child sits nearby"): someone, who may not be the streamer.
++ .replace(_LOOKED_AT, (m, art, looks, noun) => (looks ? _cap(m, 'the streamer') : _AGE_NOUN.test(noun) ? _cap(m, /^the$/i.test(art) ? 'the person' : 'someone') : m))
++ .replace(_LOOKED_AT_BARE, (m) => _cap(m, 'streamer'))
++ .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\s+and\s+/gi, ' ')
++ .replace(/\b(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\s+and\s+/gi, '')
++ .replace(/\b(one|the other|another),?\s+and\s+(?=\w+ing\b)/gi, '$1 ')
++ .replace(/\s+(?:is|are)\s+(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\b/gi, ' is on camera')
++ .replace(/\s*,\s*(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\s*,/gi, '')
++ .replace(/\b(?:shirtless|topless|bare-chested|half-naked|semi-naked|naked|nude|semi-nude|undressed|unclothed)\b,?\s*/gi, '');
++}
++
++// FNV-1a over the rules' source, twice with different offsets: a stable 16-hex-digit version.
++function _hash(text) {
++ let a = 0x811c9dc5, b = 0x01000193 ^ 0x5bd1e995;
++ for (let i = 0; i < text.length; i++) {
++ const c = text.charCodeAt(i);
++ a = Math.imul(a ^ c, 0x01000193) >>> 0;
++ b = Math.imul(b ^ c, 0x01000193 + 2) >>> 0;
++ }
++ return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0');
++}
++// What a scene shows, not how someone looks: the looks rules take out an adjective ("a naked man … reaching toward
++// their genitals" → "a man … reaching toward their genitals"), so a service asks isExplicit() before it publishes AI text
++// at all. Live's AI auto-clip 483 went public with such a description on 2026-10-23 (Live#344 had its own copy first).
++const _EXPLICIT = /\b(?:genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|sexual(?:ly)?\s+(?:acts?|activity|content|material|explicit|intercourse)|sex\s+acts?|(?:explicit|adult|pornographic|sexual)\s+(?:content|material|footage|scenes?|imagery|videos?|acts?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|nudity|erotica?)\b/i;
++function _isExplicit(...texts) {
++ return texts.some((t) => t != null && _EXPLICIT.test(String(t)));
++}
++
++const RULES_VERSION = _hash([_EXPLICIT, _NAMED, _LOOKS, _PEOPLE, _WORN, _LOWER, _WORN_ANY, _FACE, _WITH_CLOTHES, _GENDER_ADJ, _GROOMING, _LOOKS_TAG, _LOOKS_WORD, _POSE,
++ _noLooks, _noLooksWords, _noGender, _noGrooming, _noClothing, _HE_SHE, _HER_OBJECT, _PRONOUNS.map(([re, w]) => `${re}${w}`).join(','), _noPronouns].map(String).join('|'));
++
++module.exports = { noLooks: _noLooks, noLooksTags: _noLooksTags, hasLooks: _hasLooks, isExplicit: _isExplicit, RULES_VERSION };
ddiff --git a/server/patch-notes/review.js b/server/patch-notes/review.js
nnew file mode 100644
iindex 0000000..a2849f5
---- /dev/null
++++ b/server/patch-notes/review.js
@@@ -0,0 +1,142 @@
++'use strict';
++/**
++ * The review gate (devblog §3.6, §3.8): a Patch notes post is only ever a draft until a person approves it.
++ *
++ * evaluate() runs the checks on the draft and returns what the reviewer sees: the story map, the blocking issues,
++ * the warnings, and a checklist. Hard gates block publishing — a banned phrase (unless the editor overrode it with
++ * a reason), a looks word, an unresolved [TODO], a broken link, an attached image without alt text, AI-only text
++ * without a person's approval, or no approval at all. Warnings do not block: a title with a count, a section with
++ * no screenshot, a commit subject in the story.
++ *
++ * Links are checked for shape always; the HTTP check runs when the caller gives verifyLinks (the editor does; a
++ * draft made inside the worker does not, so a GitHub hiccup never stops the draft from being filed).
++ */
++const { scan } = require('./slop');
++const { hasLooks, isExplicit } = require('./no-looks');
++
++const H2 = /^##\s+(.+)$/gm;
++const LINK = /\[([^\]]*)\]\(([^)\s]+)\)/g;
++const TODO = /\[(TODO|FIXME|TBD)[^\]]*\]/i;
++const countWords = (s) => String(s || '').trim().split(/\s+/).filter(Boolean).length;
++const storyPart = (body) => String(body || '').split(/<details/i)[0];
++
++function headingsOf(body) {
++ const out = [];
++ H2.lastIndex = 0;
++ let m;
++ while ((m = H2.exec(String(body || '')))) out.push(m[1].trim());
++ return out;
++}
++
++function linksOf(body) {
++ const out = [];
++ LINK.lastIndex = 0;
++ let m;
++ while ((m = LINK.exec(String(body || '')))) out.push({ text: m[1], url: m[2] });
++ for (const m of String(body || '').matchAll(/(?<![("])\bhttps?:\/\/[^\s)<]+/g)) out.push({ text: '', url: m[0] });
++ return out;
++}
++
++function whatsNextBullets(body) {
++ const m = String(body || '').match(/^##\s+What's next\s*([\s\S]*?)(?=^##\s|(?![\s\S]))/im);
++ if (!m) return [];
++ return m[1].split('\n').filter((l) => /^\s*[-*]\s+/.test(l)).map((l) => l.replace(/^\s*[-*]\s+/, '').trim());
++}
++
++/**
++ * checkDraft:
++ * title, dek, body, tags, captions, attachments [{role, alt}], entries (the commits), storyMap,
++ * review ({decision, reviewer}), authorship ({mode, workflow} or null), override ({reason, phrases}),
++ * verifyLinks ((url) => Promise<boolean>) optional.
++ */
++async function evaluate(draft) {
++ const { title = '', dek = '', body = '', tags = [], captions = [], attachments = [], entries = [], storyMap = [], review = null, authorship: rec = null, override = null, verifyLinks = null } = draft;
++ const blocking = [];
++ const warnings = [];
++ const checklist = [];
++ const add = (list, code, message, extra = {}) => list.push({ code, message, ...extra });
++
++ // ── slop and looks ──
++ const slop = scan({ title, dek, body, tags, captions });
++ const forgiven = new Set(((override && override.phrases) || []).map((p) => String(p).toLowerCase()));
++ for (const b of slop.blocking) {
++ if (forgiven.has(b.phrase)) warnings.push({ code: 'slop.overridden', where: b.where, message: `Overridden by the editor: "${b.phrase}".`, detail: override && override.reason });
++ else blocking.push(b);
++ }
++ warnings.push(...slop.warnings);
++ const looksText = [title, dek, body, ...tags, ...captions].join('\n');
++ if (hasLooks(looksText)) add(blocking, 'looks.person', 'The text describes a person (looks, body, clothing, hair, age or a guessed gender). Public AI text never does. Say "the person" or "the streamer", or remove the sentence.');
++ if (isExplicit(title, dek, body)) add(blocking, 'looks.explicit', 'The text describes sexual content. It cannot be published.');
++ checklist.push({ code: 'voice', label: 'The voice card is followed; no banned phrases; no looks language', ok: !blocking.some((b) => /^(slop|looks)\./.test(b.code)), detail: blocking.filter((b) => /^(slop|looks)\./.test(b.code)).length ? `${blocking.filter((b) => /^(slop|looks)\./.test(b.code)).length} blocking` : 'clean' });
++
++ // ── unresolved placeholders ──
++ if (TODO.test(body) || TODO.test(title) || TODO.test(dek)) add(blocking, 'draft.todo', 'An unresolved [TODO] is still in the draft.');
++
++ // ── links ──
++ const links = linksOf(body);
++ const badShape = links.filter((l) => !/^https?:\/\//i.test(l.url) && !l.url.startsWith('#'));
++ for (const l of badShape) add(blocking, 'link.invalid', `"${l.text || l.url}" is not an absolute http(s) link.`, { url: l.url });
++ let broken = [];
++ if (typeof verifyLinks === 'function') {
++ for (const l of links.filter((x) => /^https?:\/\//i.test(x.url))) {
++ try { if (!await verifyLinks(l.url)) broken.push(l.url); } catch { broken.push(l.url); }
++ }
++ for (const url of broken) add(blocking, 'link.broken', `The link does not resolve: ${url}`, { url });
++ }
++ checklist.push({ code: 'links', label: 'Every link resolves', ok: badShape.length === 0 && broken.length === 0, detail: badShape.length || broken.length ? `${badShape.length + broken.length} problem${badShape.length + broken.length === 1 ? '' : 's'}` : (typeof verifyLinks === 'function' ? 'checked' : 'shape checked; HTTP check runs in the editor') });
++
++ // ── images and alt text ──
++ const images = attachments.filter((a) => a && a.role && a.role !== 'reference');
++ const noAlt = images.filter((a) => !String(a.alt || '').trim());
++ for (const a of noAlt) add(blocking, 'media.alt_required', `The attached image ${a.mediaId || ''} has no alt text.`);
++ const headed = headingsOf(body).filter((h) => !/^(the short version|under the hood|what's next|thanks|the full list)$/i.test(h));
++ const hasImage = images.length > 0 || /!\[[^\]]*\]\([^)]*\)/.test(body);
++ const reasons = (body.match(/^No (?:screenshot|image):.+/gim) || []).length;
++ checklist.push({ code: 'screenshots', label: 'Every section has a screenshot, or a line saying why not', ok: hasImage || reasons >= headed.length, detail: hasImage ? `${images.length} attached` : 'no image yet (the capture pass is a later stage); a "No screenshot: …" line covers a section' });
++ if (!hasImage) warnings.push({ code: 'media.none', message: 'No screenshot. The biggest visible change should have one (§3.3 is a later stage).' });
++
++ // ── title and dek ──
++ const tw = countWords(title);
++ if (tw < 5 || tw > 9) warnings.push({ code: 'title.length', message: `The title is ${tw} words; 5–9 reads best.`, detail: title });
++ if (/^patch notes?\s*:/i.test(title)) warnings.push({ code: 'title.prefix', message: 'The series already says Patch notes; drop the prefix.' });
++ if (/\b\d+\b/.test(title) || /\b(changes|sites|commits)\b/i.test(title)) warnings.push({ code: 'title.count', message: 'The title counts something. Say what the reader gets instead.', detail: title });
++ const dw = countWords(dek);
++ if (dek && (dw < 20 || dw > 35)) warnings.push({ code: 'dek.length', message: `The dek is ${dw} words; 20–35 is one sentence for the feed.`, detail: dek });
++ checklist.push({ code: 'title', label: 'The title and dek match the biggest change', ok: tw >= 5 && tw <= 9 && dw >= 20 && dw <= 35 && !/^patch notes?\s*:/i.test(title), detail: `${tw}-word title, ${dw}-word dek` });
++
++ // ── structure ──
++ for (const h of headingsOf(body)) {
++ if (/\bOpenVibe\.[A-Za-z]+\b/.test(h)) warnings.push({ code: 'heading.repo', message: `"${h}" is a repository name. Name the player-facing thing.`, detail: h });
++ }
++ const story = storyPart(body);
++ const leaked = entries.filter((e) => e.subject && e.subject.length >= 18 && story.toLowerCase().includes(String(e.subject).toLowerCase()));
++ for (const e of leaked) warnings.push({ code: 'story.commit_subject', message: 'A commit subject appears in the story; it belongs in the full list.', detail: e.subject });
++ if (/^##\s+(lockfiles?|version pins?|dependencies)\b/im.test(story)) warnings.push({ code: 'heading.chore', message: 'A chore is a heading. Chores go under "Under the hood".' });
++
++ // ── what's next ──
++ if (/^##\s+What's next\s*$/im.test(body)) {
++ const bullets = whatsNextBullets(body);
++ const sourced = bullets.filter((b) => /https?:\/\//.test(b)).length;
++ checklist.push({ code: 'next', label: "What's next names a real PR, plan line or feed entry", ok: bullets.length > 0 && sourced === bullets.length, detail: `${sourced} of ${bullets.length} linked` });
++ if (bullets.length && sourced < bullets.length) warnings.push({ code: 'next.unlinked', message: "An item in What's next has no link to its PR, plan line or feed entry." });
++ if (bullets.length > 3) warnings.push({ code: 'next.too_many', message: "What's next has more than three items." });
++ }
++ checklist.push({ code: 'evidence', label: 'Every claim traces to a commit, PR or source', ok: entries.length > 0, detail: `${entries.length} commit${entries.length === 1 ? '' : 's'} in the full list; ${storyMap.length} workstream${storyMap.length === 1 ? '' : 's'}` });
++
++ // ── authorship and review ──
++ const mode = rec && rec.mode;
++ const approved = Boolean(review && review.decision === 'approved' && /^usr_/.test(String(review.reviewer || '')));
++ checklist.push({ code: 'disclosure', label: 'The AI disclosure matches how the post was made', ok: mode !== 'ai' || Boolean(rec.workflow && rec.workflow.runId), detail: mode ? `${mode}${rec.workflow ? ` (${rec.workflow.id})` : ''}` : 'unknown' });
++ if (mode === 'ai' && !approved) add(blocking, 'review.ai_required', 'The text is AI-generated and no person has approved this revision yet.');
++ if (!approved) add(blocking, 'review.required', 'A Patch notes post needs a named person to approve the revision before it can be published.');
++ checklist.push({ code: 'review', label: 'A named human reviewer approved it', ok: approved, detail: approved ? `${review.reviewer}${review.note ? `: ${review.note}` : ''}` : 'not yet' });
++
++ return { blocking, warnings, checklist };
++}
++
++/** The publish gate: blocking issues already overridden? Only slop phrases may be. */
++function mayPublish(result) {
++ return !result.blocking.length;
++}
++
++module.exports = { evaluate, mayPublish, headingsOf, linksOf, whatsNextBullets };
ddiff --git a/server/patch-notes/slop.js b/server/patch-notes/slop.js
nnew file mode 100644
iindex 0000000..c3eaa10
---- /dev/null
++++ b/server/patch-notes/slop.js
@@@ -0,0 +1,169 @@
++'use strict';
++/**
++ * The slop check (devblog §3.7, §4.5): a deterministic copy editor, not an AI detector.
++ *
++ * A word is not banned because a model likes it; it is banned as filler ("seamless warping" is a feature name,
++ * "a seamless experience" is slop). Blocking: a phrase from the list, and a looks word (no-looks). Warnings: the
++ * structures that give generated prose away — "not only X, but also Y", "it's not just X, it's Y", a three-item
++ * list in every paragraph, one length for every paragraph, an em dash every other sentence, a conclusion that
++ * restates the intro.
++ *
++ * The full list under <details> is exempt (commit subjects are exact names), and so are code spans, fenced blocks
++ * and URLs. Output is inline: the phrase, the line, an excerpt and a plain thing to say instead. No score — a score
++ * invites gaming and is not a detection result.
++ */
++
++const AI_WORDS = ['delve', 'tapestry', 'testament', 'pivotal', 'seamless', 'leverage', 'robust', 'foster', 'underscore',
++ 'showcase', 'vibrant', 'crucial', 'multifaceted', 'landscape', 'realm', 'journey', 'nestled', 'boasts', 'align',
++ 'resonate', 'enhance', 'elevate', 'empower', 'unlock', 'game-changer', 'cutting-edge', 'revolutionary', 'transform',
++ 'transforms', 'transformed', 'transforming', 'game-changing'];
++const AI_PHRASES = ['it\'s important to note', 'it is important to note', 'in today\'s fast-paced world', 'at the end of the day',
++ 'in conclusion', 'let\'s dive in', 'buckle up', 'stay tuned', 'more to come', 'we\'re excited to announce',
++ 'we are excited to announce', 'we\'ve been hard at work', 'we have been hard at work', 'we hear you',
++ 'you asked, we delivered', 'game on', 'chef\'s kiss', 'plot twist', 'spoiler alert'];
++const OUR_JARGON = ['pin', 'pins', 'pin round', 'pin rounds', 'cqw', 'release lines', 'entry', 'entries', 'repo',
++ 'repos', 'PR #', 'fleet audit', 'workstream', 'workstreams', 'dogfood'];
++const HYPE = ['major', 'huge', 'massive', 'incredible', 'amazing', 'delight', 'love', 'we\'re proud', 'we are proud'];
++const STRUCTURE_WORDS = ['overall', 'in summary', 'to sum up', 'all in all', 'despite its challenges', 'challenges and legacy',
++ 'future outlook'];
++
++/** Every blocked phrase, deduplicated and longest first (so "pin round" is found before "pin"). */
++const BANNED = [...new Set([...AI_PHRASES, ...AI_WORDS, ...OUR_JARGON, ...HYPE, ...STRUCTURE_WORDS])]
++ .filter((p) => p && p !== 'was v')
++ .sort((a, b) => b.length - a.length);
++
++const REWRITES = {
++ leverage: 'say what the thing does',
++ delve: 'say what you looked at',
++ seamless: 'say what did not break',
++ robust: 'say what it survives',
++ crucial: 'say what happens without it',
++ enhance: 'say what is better',
++ elevate: 'say what is higher',
++ empower: 'say who can do what now',
++ unlock: 'say what you can do now',
++ showcase: 'show it',
++ transform: 'say what changed',
++ journey: 'say what happened',
++ pivotal: 'say why it mattered',
++ tapestry: 'say what it is made of',
++ testament: 'say what proves it',
++ vibrant: 'say what the colours or the people are',
++ landscape: 'say what the field is',
++ realm: 'say where it happens',
++ nestled: 'say where it sits',
++ boasts: 'say what it has',
++ align: 'say what now matches',
++ resonate: 'say who it reached',
++ foster: 'say what it caused',
++ underscore: 'say what it shows',
++ multifaceted: 'say which parts',
++ 'it\'s important to note': 'delete it and say the thing',
++ 'at the end of the day': 'say what wins',
++ 'in conclusion': 'delete it; the last paragraph is the conclusion',
++ 'stay tuned': 'name the next thing and its date, or say nothing',
++ 'more to come': 'name the next thing, or say nothing',
++ 'we\'re excited to announce': 'announce it',
++ 'we hear you': 'say what changed because of them',
++ 'we\'re proud': 'show the work instead',
++};
++
++const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
++const BANNED_RE = new RegExp(`(?<![\\w-])(?:${BANNED.map(escapeRe).join('|')})(?![\\w-])`, 'gi');
++const BANNED_SET = new Set(BANNED);
++
++/** The prose of a Markdown body: the full list, fenced code, inline code, link URLs and HTML are not prose. */
++function proseOf(body) {
++ return String(body || '')
++ .replace(/<details[\s\S]*?<\/details>/gi, ' ')
++ .replace(/```[\s\S]*?```/g, ' ')
++ .replace(/`[^`\n]*`/g, ' ')
++ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
++ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
++ .replace(/<[^>]+>/g, ' ')
++ .replace(/https?:\/\/\S+/g, ' ');
++}
++function linesOf(text) {
++ return String(text || '').split('\n');
++}
++const lineAt = (text, index) => String(text || '').slice(0, index).split('\n').length;
++
++/** Phrases in one text: [{ phrase, line, excerpt, message }]. */
++function phrasesIn(text) {
++ const prose = proseOf(text);
++ const out = [];
++ BANNED_RE.lastIndex = 0;
++ let m;
++ while ((m = BANNED_RE.exec(prose))) {
++ const phrase = m[0].toLowerCase();
++ if (!BANNED_SET.has(phrase) && !BANNED_SET.has(phrase.replace(/\s+/g, ' '))) continue;
++ const at = m.index;
++ const line = lineAt(prose, at);
++ const excerpt = prose.slice(Math.max(0, at - 40), Math.min(prose.length, at + phrase.length + 40)).replace(/\s+/g, ' ').trim();
++ out.push({ phrase, line, excerpt, message: `This says "${phrase}". ${REWRITES[phrase] ? REWRITES[phrase].charAt(0).toUpperCase() + REWRITES[phrase].slice(1) : 'Say the thing itself.'}` });
++ }
++ return out;
++}
++
++const paragraphs = (prose) => prose.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p && !/^#{1,6}\s/.test(p) && !/^[-*]\s/.test(p));
++const sentencesOf = (prose) => prose.replace(/\s+/g, ' ').split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter((s) => s.split(/\s+/).length > 2);
++const stddev = (xs) => { if (!xs.length) return 0; const mean = xs.reduce((a, b) => a + b, 0) / xs.length; return Math.sqrt(xs.reduce((a, b) => a + (b - mean) ** 2, 0) / xs.length); };
++const wordsOf = (s) => s.split(/\s+/).filter(Boolean).length;
++
++/** The structures, as warnings: [{ code, message, detail }]. */
++function structuresIn(body) {
++ const prose = proseOf(body);
++ const out = [];
++ const words = wordsOf(prose);
++ if (/\bnot only\b[^.!?]{0,160}\bbut also\b/i.test(prose)) out.push({ code: 'structure.not_only', message: 'The "not only X, but also Y" shape is a tell. Say both things as two sentences.', detail: 'not only … but also' });
++ if (/\bit(?:'| i)s not (?:just|only)\b[^.!?]{0,100}[,;]\s*it(?:'| i)s\b/i.test(prose)) out.push({ code: 'structure.not_just', message: 'The "it\'s not just X, it\'s Y" shape is a tell. Say what it is, then what else it is.', detail: 'it\'s not just X, it\'s Y' });
++ for (const w of ['Despite its challenges', 'Challenges and Legacy', 'Future Outlook']) if (new RegExp(`\\b${w}\\b`, 'i').test(prose)) out.push({ code: 'structure.essay_cliche', message: `"${w}" is the shape of a generated essay, not a patch note.`, detail: w });
++ const paras = paragraphs(prose);
++ const emDashes = (prose.match(/—/g) || []).length;
++ if (words >= 100 && emDashes > words / 100) out.push({ code: 'structure.em_dash', message: `${emDashes} em dashes in ${words} words. Use a full stop.`, detail: `${emDashes} dashes / ${words} words` });
++ const boldParas = paras.filter((p) => (p.match(/\*\*[^*\n]+\*\*/g) || []).length > 1);
++ if (boldParas.length) out.push({ code: 'structure.bold', message: `Bold on more than one phrase in ${boldParas.length} paragraph${boldParas.length === 1 ? '' : 's'}. Bold one thing, or nothing.`, detail: boldParas[0].slice(0, 80) });
++ const lists = paras.filter((p) => /\b\w+(?:,\s*\w+){2},?\s+(?:and|or)\s+\w+/i.test(p) || (p.split('\n').filter((l) => /^\s*[-*]\s+/.test(l)).length === 3));
++ if (paras.length >= 3 && lists.length / paras.length >= 0.6) out.push({ code: 'structure.three_item_lists', message: 'Almost every paragraph carries a three-item list. Vary the shape; a list is not a paragraph.', detail: `${lists.length} of ${paras.length} paragraphs` });
++ const openers = new Map();
++ for (const p of paras) {
++ const opener = p.replace(/[*_`#>]/g, '').split(/\s+/).slice(0, 3).join(' ').toLowerCase();
++ if (opener.split(' ').length === 3) openers.set(opener, (openers.get(opener) || 0) + 1);
++ }
++ for (const [opener, n] of openers) if (n >= 3) out.push({ code: 'structure.same_opener', message: `"${opener}" opens ${n} paragraphs. Start one of them differently.`, detail: opener });
++ const sentences = sentencesOf(prose);
++ if (sentences.length >= 8) {
++ const sd = stddev(sentences.map(wordsOf));
++ if (sd < 2.5) out.push({ code: 'structure.uniform_sentences', message: `Every sentence is about ${Math.round(sentences.map(wordsOf).reduce((a, b) => a + b, 0) / sentences.length)} words. Let one be short and one be longer.`, detail: `sd ${sd.toFixed(1)}` });
++ }
++ if (paras.length >= 4) {
++ const lens = paras.map((p) => wordsOf(p.replace(/\n/g, ' ')));
++ const mean = lens.reduce((a, b) => a + b, 0) / lens.length;
++ if (mean > 10 && (Math.max(...lens) - Math.min(...lens)) / mean <= 0.25) out.push({ code: 'structure.uniform_paragraphs', message: 'Every paragraph is the same length. A one-line paragraph is allowed.', detail: `mean ${Math.round(mean)} words` });
++ }
++ const last = paras[paras.length - 1] || '';
++ const first = paras[0] || '';
++ if (/^(in conclusion|in summary|overall|to sum up|all in all)\b/i.test(last)) out.push({ code: 'structure.restating_conclusion', message: 'The last paragraph announces a conclusion and restates the post. End on the last fact.', detail: last.slice(0, 80) });
++ else if (paras.length >= 3 && first && last) {
++ const stop = new Set('a an the and or but of to in on for with is are was were this that it its we our you your they their as at by from be been has have had not no so if then than more most all any'.split(' '));
++ const nouns = (s) => new Set(s.toLowerCase().match(/[a-z]{4,}/g) || []);
++ const f = nouns(first); const l = nouns(last);
++ if (f.size >= 6 && l.size >= 6 && [...f].filter((w) => l.has(w) && !stop.has(w)).length / Math.min(f.size, l.size) >= 0.6) out.push({ code: 'structure.restating_conclusion', message: 'The last paragraph repeats the first one. Cut it or end on a fact.', detail: last.slice(0, 80) });
++ }
++ return out;
++}
++
++/** Everything the slop check says about a post: blocking phrases and warning structures. */
++function scan({ title = '', dek = '', body = '', tags = [], captions = [] } = {}) {
++ const blocking = [];
++ const texts = [['title', title], ['dek', dek], ['body', body], ...[...tags].map((t, i) => [`tag ${i + 1}`, String(t)]), ...[...captions].map((c, i) => [`caption ${i + 1}`, String(c)])];
++ for (const [where, text] of texts) {
++ for (const p of phrasesIn(text)) blocking.push({ code: 'slop.phrase', where, ...p });
++ }
++ const warnings = structuresIn(body).map((w) => ({ ...w, where: 'body' }));
++ if (/\b(?:the|this|that|latest) shipped\b/i.test(proseOf(body))) warnings.push({ code: 'slop.shipped_noun', where: 'body', message: '"Shipped" is a verb here, not a noun. Say what shipped, or say it landed.' });
++ if (tags.some((t) => /^(major|huge|amazing|incredible)$/i.test(String(t)))) warnings.push({ code: 'slop.tag', where: 'tags', message: 'A tag is not a mood ("major"). Name the thing it is about.' });
++ return { blocking, warnings };
++}
++
++module.exports = { scan, phrasesIn, structuresIn, proseOf, BANNED, REWRITES };
ddiff --git a/server/patch-notes/stories.js b/server/patch-notes/stories.js
nnew file mode 100644
iindex 0000000..0743cf6
---- /dev/null
++++ b/server/patch-notes/stories.js
@@@ -0,0 +1,163 @@
++'use strict';
++/**
++ * Patch notes v2, steps 4–6: commits become stories (devblog §3.2).
++ *
++ * The unit is a workstream, not a commit. Entries cluster by intent first (text.intentOf), then by an explicit
++ * `Workstream:` trailer, then by a package they share; a gap of more than 72 hours starts a new cluster. Each
++ * cluster is classified (feature | fix | chore | internal) and ranked by player impact — how many sites it reaches,
++ * how many commits carry it, and how central the page is — not by how many lines the diff had. A 2,000-line
++ * dependency bump is not a story; it is Under the hood.
++ *
++ * build(entries) → {
++ * stories: user-visible workstreams, most impactful first (each with entries, sites, score)
++ * also: user-visible entries outside the top stories (one line each in the post)
++ * chores: lockfiles, package versions and docs (one Under the hood paragraph)
++ * internal: no user-visible surface (not written about; in the full list)
++ * }
++ * The four workstreams the 29–30 October batch is known by have fixed headlines; every other one gets a headline
++ * from its first subject, because a headline nobody recognises is worse than a plain one.
++ */
++const { normaliseSubject, intentOf, siteLabel } = require('./text');
++
++const WINDOW_MS = 72 * 3600 * 1000;
++const NUMBERS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve',
++ 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'];
++const TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty'];
++
++/** 23 → "twenty-three" (counts in headlines read better as words than as digits). */
++function words(n) {
++ if (n < 20) return NUMBERS[n];
++ if (n < 100) return `${TENS[Math.floor(n / 10)]}${n % 10 ? `-${NUMBERS[n % 10]}` : ''}`;
++ return String(n);
++}
++const plural = (n, one, many = `${one}s`) => (n === 1 ? one : many);
++
++/** How central and how visible a workstream is (ranking is player impact, never line count). */
++const WEIGHT = { 'sign-in': 30, 'phone-home': 25, touch: 20, orbit: 35, comfort: 10, mcp: 8, outbox: 5, docs: 0, chore: 0, other: 4 };
++const HEADLINES = {
++ 'sign-in': (n) => `One sign-in, ${words(n)} ${plural(n, 'site')}`,
++ touch: '44px, or it doesn\'t count',
++ 'phone-home': 'Homes that scroll sideways',
++ orbit: 'The orbit stays put',
++ comfort: 'Comfort settings that follow you',
++ mcp: 'The design system, now as tools',
++};
++
++function headlineFor(key, entries) {
++ const sites = new Set(entries.map((e) => e.service)).size;
++ // "Wider than a phone" is a fit fix, not a sideways row: say which one this round is.
++ if (key === 'phone-home' && entries.every((e) => /wider than a phone/i.test(String(e.subject)))) return 'Homes that fit a phone';
++ const h = HEADLINES[key];
++ if (typeof h === 'function') return h(sites);
++ if (h) return h;
++ const first = normaliseSubject(entries[0]);
++ const short = first.length > 64 ? `${first.slice(0, 61).replace(/[,;:\s]+$/, '')}…` : first;
++ return short.charAt(0).toUpperCase() + short.slice(1);
++}
++
++/** A commit subject that is only housekeeping: a version, a lockfile, docs, tests, CI, a merge. */
++function isChoreKind(kind) { return kind === 'chore'; }
++
++/**
++ * One story per intent (or explicit Workstream trailer), split when its entries are more than 72 hours apart.
++ * `entries` carry service, repo, sha, subject, body?, files?, deployed_at.
++ */
++function cluster(entries) {
++ const sorted = [...entries]
++ .map((e) => ({ ...e, _at: Date.parse(e.deployed_at || e.committed_at || 0) || 0 }))
++ .sort((a, b) => a._at - b._at);
++ const groups = new Map();
++ for (const e of sorted) {
++ const { intent: key, kind } = intentOf(e);
++ if (!groups.has(key)) groups.set(key, { key, kind, entries: [], lastAt: 0 });
++ const g = groups.get(key);
++ // A workstream is one intent within 72 hours; a later commit of the same words is a new round.
++ if (g.entries.length && e._at - g.lastAt > WINDOW_MS) {
++ let n = 2;
++ while (groups.has(`${key}#${n}`)) n++;
++ groups.set(`${key}#${n}`, { key, kind, entries: [e], lastAt: e._at });
++ continue;
++ }
++ g.entries.push(e); g.lastAt = e._at;
++ }
++ return [...groups.values()];
++}
++
++/**
++ * The module a commit belongs to, when it names one: `openvibe-sdk/sso`, `server/auth/sso.js` and
++ * "sign-in" are the same workstream. Kept narrow on purpose — `public/css/app.css` is shared by most
++ * sites and would merge unrelated stories.
++ */
++function packageKey(entry) {
++ const subject = String(entry.subject || '');
++ if (/\bopenvibe-sdk\/sso\b/i.test(subject) || /\bsign[- ]?in\b|\bsso\b|\boauth\b/i.test(subject)) return 'sso';
++ for (const f of Array.isArray(entry.files) ? entry.files : []) {
++ if (/(?:^|\/)server\/auth\/sso\.[cm]?js$/.test(f) || /openvibe-sdk\/sso/i.test(f)) return 'sso';
++ }
++ return null;
++}
++
++/** Two rounds of the same module merge; the groups' entries keep their order. */
++function mergeByPackage(groups) {
++ const out = [];
++ for (const g of groups) {
++ if (g.kind === 'chore') { out.push(g); continue; }
++ const keys = new Set(g.entries.map(packageKey).filter(Boolean));
++ if (!keys.size) { out.push(g); continue; }
++ const hit = out.find((x) => x.kind !== 'chore' && x.entries.some((e) => keys.has(packageKey(e))));
++ if (hit) { hit.entries.push(...g.entries); hit.lastAt = Math.max(hit.lastAt, g.lastAt); } else out.push(g);
++ }
++ return out;
++}
++
++/** A shared package pulls two commits of different intents together only when both are chores. */
++function mergeSharedChores(groups) {
++ const chores = groups.filter((g) => isChoreKind(g.kind));
++ if (chores.length < 2) return groups;
++ const rest = groups.filter((g) => !isChoreKind(g.kind));
++ const all = chores.flatMap((g) => g.entries);
++ return [...rest, { key: 'chore', kind: 'chore', entries: all, lastAt: Math.max(...all.map((e) => Date.parse(e.deployed_at) || 0)) }];
++}
++
++function scoreOf(group) {
++ const sites = new Set(group.entries.map((e) => e.service)).size;
++ const weight = WEIGHT[group.key] == null ? WEIGHT.other : WEIGHT[group.key];
++ return sites * 10 + group.entries.length * 2 + weight + (group.kind === 'fix' ? 5 : 0);
++}
++
++/**
++ * The whole batch as a story map. `maxStories` caps the H2s; every user-visible entry outside them becomes an
++ * "Also shipped" line, so nothing with a user-facing surface disappears.
++ */
++function build(entries, { maxStories = 6 } = {}) {
++ const groups = mergeSharedChores(mergeByPackage(cluster(entries)));
++ const stories = [];
++ const chores = [];
++ const internal = [];
++ for (const g of groups) {
++ if (g.kind === 'chore') { chores.push(g); continue; }
++ if (g.kind === 'internal' || g.key === 'outbox') { internal.push(g); continue; }
++ stories.push(g);
++ }
++ for (const s of stories) {
++ s.sites = [...new Set(s.entries.map((e) => e.service))].sort();
++ s.score = scoreOf(s);
++ s.title = headlineFor(s.key, s.entries);
++ }
++ stories.sort((a, b) => b.score - a.score || b.sites.length - a.sites.length || a.key.localeCompare(b.key));
++ const top = stories.slice(0, maxStories);
++ const also = stories.slice(maxStories).flatMap((s) => s.entries);
++ return { stories: top, also, chores: chores.flatMap((g) => g.entries), internal: internal.flatMap((g) => g.entries), groups };
++}
++
++/** The site names of a story, as a sentence: "Play, Search and Coupons". */
++function sitesSentence(entries) {
++ const names = [...new Set(entries.map((e) => siteLabel(e)))]
++ .filter(Boolean)
++ .sort((a, b) => a.localeCompare(b));
++ const shown = names.length > 12 ? [...names.slice(0, 12), `${names.length - 12} more`] : names;
++ if (shown.length <= 1) return shown[0] || '';
++ return `${shown.slice(0, -1).join(', ')} and ${shown[shown.length - 1]}`;
++}
++
++module.exports = { build, cluster, words, plural, sitesSentence, headlineFor, WEIGHT, WINDOW_MS };
ddiff --git a/server/patch-notes/text.js b/server/patch-notes/text.js
nnew file mode 100644
iindex 0000000..041bfde
---- /dev/null
++++ b/server/patch-notes/text.js
@@@ -0,0 +1,80 @@
++'use strict';
++/**
++ * Patch notes v2, step 3: a commit subject becomes a workstream (devblog §3.2).
++ *
++ * normaliseSubject strips the site prefix the repository already tells us ("Play:", "MediaHub:", "Help:"), the PR
++ * number and "(was vX)". intentOf maps the words people actually use onto one of the workstreams the network
++ * knows (sign-in, touch, phone-home, orbit, comfort, MCP, outbox, docs, chore) — "sso", "sign-in" and "login" are
++ * the same story. classify says what kind of change it is: feature, fix, chore (a lockfile, a package version, docs)
++ * or internal (no user-visible surface). An explicit commit trailer wins over the words:
++ *
++ * Workstream: sign-in
++ * Player-facing: yes
++ * Sites: design, codes, play
++ */
++
++const SITE_SUFFIX = '(?:Hub|Network|Restream|Vibe\\.[A-Za-z]+)';
++
++/** The site's own name, from the service id or the repository ("OpenVibe.MediaHub" → "MediaHub"). */
++function siteLabel(entry = {}) {
++ const repo = String(entry.repo || '').split('/').pop() || '';
++ if (repo) {
++ const m = repo.match(/^OpenVibe\.(.+)$/);
++ if (m) return m[1];
++ if (repo === 'OpenRestream') return 'OpenRestream';
++ }
++ const service = String(entry.service || '');
++ return service ? service.split(/[-_]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('') : '';
++}
++
++/** The subject without the site prefix, the PR number and "(was vX)". */
++function normaliseSubject(entryOrSubject, entry = {}) {
++ const raw = typeof entryOrSubject === 'string' ? entryOrSubject : String((entryOrSubject && entryOrSubject.subject) || '');
++ const label = typeof entryOrSubject === 'string' ? (entry.repo || entry.service ? siteLabel(entry) : '') : siteLabel(entryOrSubject);
++ let s = raw.replace(/\s+/g, ' ').trim();
++ if (label) {
++ const p = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
++ s = s.replace(new RegExp(`^\\s*${p}\\s*:\\s+`, 'i'), '');
++ s = s.replace(new RegExp(`\\s*\\(${p}\\)\\s*$`, 'i'), '');
++ }
++ // An "OpenVibe.X: " or "X.y: " prefix is the site too.
++ s = s.replace(new RegExp(`^\\s*OpenVibe\\.[A-Za-z]+\\s*:\\s+`), '');
++ s = s.replace(/\s*\(#\d+\)\s*$/, '');
++ s = s.replace(/\s*\((?:was\s+)?v?\d+\.\d+\.\d+\)\s*$/i, '');
++ return s.replace(/\s+/g, ' ').trim();
++}
++
++const WORKSTREAM_TRAILER = /^\s*Workstream\s*:\s*(.+?)\s*$/im;
++const PLAYER_TRAILER = /^\s*Player-facing\s*:\s*(yes|no)\s*$/im;
++
++/** { intent, kind } for one entry. `intent` is the workstream key; `kind` is feature | fix | chore | internal. */
++const INTENTS = [
++ ['sign-in', /\b(sign[- ]?ins?|sso|oauth|auth(?:entication)?|log[- ]?in)\b/i, 'feature'],
++ ['orbit', /\borbit\b/i, 'fix'],
++ ['touch', /\b(touch[- ]?targets?|tap[- ]?targets?|44\s?px|fleet audit|thumb)\b/i, 'fix'],
++ ['phone-home', /\b(home(?: page)? (?:on (?:phones?|touch screens?)|is wider than a phone)|wider than a phone|scroll(?:s|ing)? sideways|right-now columns|short lists|scores and ranked lists|examples scroll|tables and card lists|two newest recordings|sign-up perks|traps? a finger|finger drag|on phones?|on touch screens?)\b/i, 'fix'],
++ ['comfort', /\b(comfort settings?|comfort display|your look)\b/i, 'feature'],
++ ['mcp', /\bmcp\b/i, 'feature'],
++ ['outbox', /\boutbox\b/i, 'internal'],
++];
++const DOCS = /\bin the docs\b|^docs?\b|documentation|readme/i;
++const CHORE = /^openvibe-(?:shared|sdk|contracts|publishing)\s+v?\d|^(?:pin|bump)\b|\blockfiles?\b|package\.json|dependenc|^tidy the docs\b|^update the docs\b/i;
++
++function intentOf(entry) {
++ const body = String(entry.body || entry.message_body || '');
++ const trailer = body.match(WORKSTREAM_TRAILER);
++ if (trailer) {
++ const slug = String(trailer[1]).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
++ const known = INTENTS.some(([key]) => key === slug) || ['docs', 'chore'].includes(slug) ? slug : `ws:${slug || 'workstream'}`;
++ const face = /^\s*Player-facing\s*:\s*yes/im.test(body);
++ return { intent: known, kind: face ? 'feature' : entry.major ? 'feature' : 'fix', explicit: true };
++ }
++ const subject = normaliseSubject(entry);
++ if (DOCS.test(subject)) return { intent: 'docs', kind: 'chore' };
++ for (const [key, re, kind] of INTENTS) if (re.test(subject)) return { intent: key, kind };
++ if (CHORE.test(subject)) return { intent: 'chore', kind: 'chore' };
++ if (entry.major) return { intent: 'other', kind: 'feature' };
++ return { intent: 'other', kind: /^add\b|\bnew\b|\bnow\b|support/i.test(subject) ? 'feature' : 'fix' };
++}
++
++module.exports = { normaliseSubject, intentOf, siteLabel, INTENTS, WORKSTREAM_TRAILER, PLAYER_TRAILER };
ddiff --git a/server/patch-notes/voice.js b/server/patch-notes/voice.js
nnew file mode 100644
iindex 0000000..c74994e
---- /dev/null
++++ b/server/patch-notes/voice.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * A voice card per author account (devblog §3.4). The card belongs to the account, not the post: the official
++ * card is the seed ('official'), a person's card is their `usr_` row, and a patch notes draft uses the official
++ * card unless a developer claims a section with their own. It is short enough to read in 30 seconds and specific
++ * enough to change a sentence; the AI prompt gets it verbatim plus two samples of the same account's writing.
++ *
++ * get(account) → { account, card, public, updated_at }
++ * set(account, input) → the stored card (text ≤ 1200 chars; the known fields only)
++ * prompt(card) → the block the drafting prompt includes
++ */
++const OFFICIAL_TEXT = 'We. Energetic and specific: we\'re proud of this work and show it with the thing itself (a number, a before/after, what you can do now), never with adjectives. Short sentences. One dry joke at most, never in the first paragraph. One exclamation mark at most per section. Sign off: — the OpenVibe crew.';
++
++const FIELDS = ['name', 'role', 'pov', 'audience', 'sentenceLength', 'humour', 'jargon', 'never', 'signoff', 'text'];
++const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
++
++function seedCard() {
++ return {
++ text: OFFICIAL_TEXT,
++ name: 'OpenVibe official',
++ role: 'The network account',
++ pov: 'we',
++ audience: 'Members and contributors, not investors',
++ sentenceLength: 'Short. One idea per sentence.',
++ humour: 'Dry. One joke at most, never in the first paragraph.',
++ jargon: 'None in the story; exact names in the full list.',
++ never: ['excited', 'thrilled', 'game-changing', 'seamless as filler'],
++ signoff: '— the OpenVibe crew',
++ };
++}
++
++/** Keep the known fields, strings clipped, `never` a short list of short phrases. */
++function normaliseCard(input = {}) {
++ const out = {};
++ for (const f of FIELDS) {
++ if (input[f] === undefined) continue;
++ if (f === 'never') {
++ out.never = (Array.isArray(input.never) ? input.never : String(input.never || '').split(','))
++ .map((x) => clean(x, 60)).filter(Boolean).slice(0, 12);
++ } else out[f] = clean(input[f], f === 'text' ? 1200 : 300);
++ }
++ return out;
++}
++
++function createVoiceCards({ store }) {
++ const { db } = store;
++ const q = {
++ get: db.prepare('SELECT * FROM blog_voice_cards WHERE account = ?'),
++ put: db.prepare(`INSERT INTO blog_voice_cards (account, card, public, updated_at) VALUES (@account, @card, @public, @at)
++ ON CONFLICT (account) DO UPDATE SET card = excluded.card, public = excluded.public, updated_at = excluded.updated_at`),
++ list: db.prepare('SELECT * FROM blog_voice_cards ORDER BY account'),
++ };
++
++ async function get(account = 'official') {
++ const key = /^usr_/.test(String(account)) ? String(account) : 'official';
++ const row = await q.get.get(key);
++ if (row) return { account: row.account, card: typeof row.card === 'string' ? JSON.parse(row.card) : row.card, public: Boolean(row.public), updated_at: Number(row.updated_at) };
++ const official = key === 'official' ? null : await q.get.get('official');
++ const card = official ? (typeof official.card === 'string' ? JSON.parse(official.card) : official.card) : seedCard();
++ return { account: key, card, public: false, updated_at: 0 };
++ }
++ const official = () => get('official');
++
++ /** Store a person's card (the official seed is not overwritten by a member). */
++ async function set(account, input = {}) {
++ const key = /^usr_/.test(String(account)) ? String(account) : 'official';
++ const current = await get(key);
++ const card = { ...current.card, ...normaliseCard(input) };
++ if (key === 'official') card.text = clean(input.text, 1200) || OFFICIAL_TEXT;
++ if (!card.signoff) card.signoff = current.card.signoff || '— the OpenVibe crew';
++ await q.put.run({ account: key, card: JSON.stringify(card), public: input.public === true || input.public === '1' ? 1 : (current.public ? 1 : 0), at: store.now() });
++ return await get(key);
++ }
++
++ /** The prompt block: the card as written, field by field. */
++ function prompt(entry) {
++ const card = (entry && entry.card) || seedCard();
++ const lines = [
++ `Voice card${card.name ? ` — ${card.name}` : ''}${card.role ? ` (${card.role})` : ''}:`,
++ card.text ? `"${card.text}"` : null,
++ card.pov ? `Point of view: ${card.pov}.` : null,
++ card.audience ? `Audience: ${card.audience}.` : null,
++ card.sentenceLength ? `Sentences: ${card.sentenceLength}` : null,
++ card.humour ? `Humour: ${card.humour}` : null,
++ card.jargon ? `Jargon: ${card.jargon}` : null,
++ card.never && card.never.length ? `Never: ${card.never.join(', ')}.` : null,
++ card.signoff ? `Sign off exactly: ${card.signoff}` : null,
++ ].filter(Boolean);
++ return lines.join('\n');
++ }
++
++ return { get, official, set, prompt, seedCard, OFFICIAL_TEXT, list: async () => await q.list.all() };
++}
++
++module.exports = { createVoiceCards, seedCard, normaliseCard, OFFICIAL_TEXT };
ddiff --git a/server/render/editor.js b/server/render/editor.js
iindex 3360ea2..c0237aa 100644
---- a/server/render/editor.js
++++ b/server/render/editor.js
@@@ -28,7 +28,7 @@ ${own ? '' : h`<section class="card"><h2>Start your blog</h2>
<label for="handle">Handle</label><input id="handle" name="handle" value="${suggestedHandle || ''}" pattern="[a-z0-9][a-z0-9_-]{1,39}" required>
<label for="title">Blog title</label><input id="title" name="title" maxlength="120" value="${suggestedHandle ? `@${suggestedHandle}` : ''}">
<button type="submit">Start my blog</button></form></section>`}
--<p class="meta">Signed in as ${viewer.user && (viewer.user.display_name || viewer.user.username) ? (viewer.user.display_name || viewer.user.username) : viewer.subject}.</p>`);
++<p class="meta">Signed in as ${viewer.user && (viewer.user.display_name || viewer.user.username) ? (viewer.user.display_name || viewer.user.username) : viewer.subject}. · <a href="/write/voice">How I write</a></p>`);
}
function blogDashboard({ blog, posts, heads, role, csrf, message, publicUrl }) {
@@@ -94,12 +94,13 @@ function gateSummary(decision) {
}
function editPage(o) {
-- const { blog, post, head, published, decision, jobs, attachments, csrf, disclosure, needsReview, publicUrl, canDelete } = o;
++ const { blog, post, head, published, decision, jobs, attachments, csrf, disclosure, needsReview, publicUrl, canDelete, patchNotes } = o;
return String(h`<p><a href="/write/@${blog.handle}">← ${blog.title}</a></p>
<h1>${head.fields.title}</h1>
<p class="meta">${STATE_LABEL[post.state] || post.state} · ${post.visibility} · revision ${head.number}${published ? h` · readers see revision ${published.number}` : ''} · <a href="/write/posts/${post.id}/revisions">History</a> · <a href="/write/posts/${post.id}/preview">Preview</a>${post.state === 'published' ? h` · <a href="${publicUrl}">View</a>` : ''}</p>
${disclosure ? h`<p class="disclosure" role="note"><strong>${disclosure.short}.</strong> ${disclosure.long}</p>` : ''}
${raw(gateSummary(decision))}
++${patchNotes ? raw(patchNotesPanel({ post, draft: patchNotes, csrf })) : ''}
${needsReview ? h`<section class="card"><h2>Review needed</h2><p>Revision ${head.number} is AI-generated. A person must review it before it can be published or indexed.</p>
<form method="post" action="/write/posts/${post.id}/review">${csrfField(csrf)}<input type="hidden" name="revision" value="${head.number}">
<label for="note">Note</label><input id="note" name="note" maxlength="500">
@@@ -163,4 +164,54 @@ ${m.role !== 'owner' || blog.kind === 'official' ? h`<form method="post" action=
<button type="submit">Add member</button></form></section>`);
}
--module.exports = { home, blogDashboard, postForm, aiDraftForm, editPage, revisionsPage, settingsPage, gateSummary };
++module.exports = { home, blogDashboard, postForm, aiDraftForm, editPage, revisionsPage, settingsPage, gateSummary, patchNotesPanel, voicePage };
++
++/** The reviewer's panel for a Patch notes draft: the story map, the blockers, the warnings and the checklist. */
++function patchNotesPanel({ post, draft, csrf }) {
++ const checks = (draft && draft.checks) || { blocking: [], warnings: [], checklist: [] };
++ const stories = (draft && draft.stories) || [];
++ const slop = (checks.blocking || []).filter((b) => b.code === 'slop.phrase');
++ const mark = (ok) => (ok ? '✓' : '✗');
++ return String(h`<section class="card patch-notes"><h2>Patch notes review</h2>
++<p class="meta">This is a draft for review. Publishing is blocked while a hard gate below fails; a person's approval of this revision is also required.</p>
++${stories.length ? h`<h3>Story map</h3><ol>${stories.map((s) => h`<li><strong>${s.title}</strong> — ${s.kind}, ${s.sites.length} ${s.sites.length === 1 ? 'site' : 'sites'}${s.commits ? h` · ${s.commits.length} commits` : ''}</li>`)}</ol>` : ''}
++${checks.blocking && checks.blocking.length ? h`<div class="notice error" role="alert"><h3>Blocking</h3><ul>${checks.blocking.map((b) => h`<li><code>${b.code}</code> ${b.message}${b.excerpt ? h` <span class="meta">“${b.excerpt}”</span>` : ''}</li>`)}</ul></div>` : h`<p class="notice" role="status">No hard gate is failing.</p>`}
++${checks.warnings && checks.warnings.length ? h`<details><summary>${checks.warnings.length} warning${checks.warnings.length === 1 ? '' : 's'}</summary><ul>${checks.warnings.map((w) => h`<li><code>${w.code}</code> ${w.message}${w.detail ? h` <span class="meta">${w.detail}</span>` : ''}</li>`)}</ul></details>` : ''}
++<h3>Checklist</h3>
++<ul class="checklist">${(checks.checklist || []).map((c) => h`<li>${mark(c.ok)} ${c.label}${c.detail ? h` <span class="meta">(${c.detail})</span>` : ''}</li>`)}</ul>
++${draft && draft.override ? h`<p class="meta">Override on record: ${draft.override.reason} (${(draft.override.phrases || []).join(', ')})</p>` : ''}
++<form method="post" action="/write/posts/${post.id}/checks">${csrfField(csrf)}
++<button type="submit">Run the checks again</button></form>
++${slop.length ? h`<form method="post" action="/write/posts/${post.id}/checks">${csrfField(csrf)}
++<h3>Override a banned phrase</h3>
++<p class="meta">A blocking phrase may be overridden with a reason the reviewer can read. Looks, [TODO], links, alt text and the missing approval cannot.</p>
++<label>Phrases</label><select name="phrases" multiple size="${Math.min(6, slop.length)}">${slop.map((b) => h`<option value="${b.phrase}">${b.phrase} — ${b.where}</option>`)}</select>
++<label for="override-reason">Reason</label><input id="override-reason" name="reason" maxlength="500" required>
++<button type="submit">Override and check again</button></form>` : ''}
++</section>`);
++}
++
++/** Settings → How I write: the person's card, and the official card they inherit. */
++function voicePage({ viewer, mine, official, csrf, message }) {
++ const card = (mine && mine.card) || {};
++ const field = (name, label, value, hint = '') => h`<label for="voice-${name}">${label}${hint ? h` <span class="meta">(${hint})</span>` : ''}</label><input id="voice-${name}" name="${name}" maxlength="300" value="${value || ''}">`;
++ return String(h`<p><a href="/write">← Write</a></p>
++<h1>How I write</h1>
++${flash(message)}
++<p class="meta">This card goes into the AI drafting prompt verbatim, with two samples of your writing. It is private unless you publish it on your author page.</p>
++<form method="post" action="/write/voice">${csrfField(csrf)}
++<label for="voice-text">The card, in your own words</label><textarea id="voice-text" name="text" rows="6" maxlength="1200">${card.text || ''}</textarea>
++${field('name', 'Name on the card', card.name)}
++${field('role', 'Your role', card.role)}
++${field('pov', 'Point of view', card.pov, 'I, we or you')}
++${field('audience', 'Who you are talking to', card.audience)}
++${field('sentenceLength', 'Sentences', card.sentenceLength)}
++${field('humour', 'Humour', card.humour)}
++${field('jargon', 'Jargon', card.jargon)}
++${field('never', 'Never say', Array.isArray(card.never) ? card.never.join(', ') : card.never, 'comma-separated')}
++${field('signoff', 'Sign-off', card.signoff)}
++<label><input type="checkbox" name="public" value="1"${raw(mine && mine.public ? ' checked' : '')}> Show this card on my author page</label>
++<button type="submit">Save my card</button>
++</form>
++<section class="card"><h2>The official card</h2><p class="meta">Patch notes use this card unless a developer claims a section with their own.</p><blockquote>${official.card.text}</blockquote></section>`);
++}
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 5cc62c9..2a74a11 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -12,6 +12,25 @@ const { figureHtml } = require('openvibe-publishing/media');
const { html: h, raw, escapeHtml: esc } = ssr;
const MEDIA_LINE = /^[ \t]*\[\[media:(med_[0-9A-HJKMNP-TV-Z]{26})\]\][ \t]*$/m;
++// A post may fold a block away: <details><summary>…</summary>…</details>. The package's renderer
++// escapes raw HTML on purpose, so Blog renders exactly this one shape itself — the summary as text,
++// the inside as Markdown — and leaves every other tag to be escaped. Patch notes keep the full commit
++// list there.
++const DETAILS = /<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/gi;
++function renderMarkdownWithDetails(source, opts) {
++ const text = String(source || '');
++ let out = '';
++ let last = 0;
++ DETAILS.lastIndex = 0;
++ let m;
++ while ((m = DETAILS.exec(text))) {
++ out += ssr.renderMarkdown(text.slice(last, m.index), opts);
++ out += `<details class="ov-details"><summary>${esc(m[1].replace(/<[^>]*>/g, '').trim())}</summary>${ssr.renderMarkdown(m[2], opts)}</details>`;
++ last = m.index + m[0].length;
++ }
++ out += ssr.renderMarkdown(text.slice(last), opts);
++ return out;
++}
const dateLabel = (v) => (v ? new Date(v).toISOString().slice(0, 10) : null);
const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString(), { label: dateLabel(v) || undefined }));
@@@ -27,7 +46,7 @@ function renderBody(content, attachments, { urlFor, rel }) {
const parts = String(content || '').split(new RegExp(MEDIA_LINE.source, 'm'));
let out = '';
parts.forEach((part, i) => {
-- if (i % 2 === 0) { out += ssr.renderMarkdown(part, { rel }); return; }
++ if (i % 2 === 0) { out += renderMarkdownWithDetails(part, { rel }); return; }
const att = byId.get(part);
if (att) { used.add(att.id); out += figureHtml(att, { urlFor }); } else {
out += `<figure class="ov-media ov-media-broken" data-media-id="${esc(part)}" data-state="not_attached"><div class="ov-media-missing" role="img" aria-label="Media not attached">This media is not attached to the post.</div></figure>`;
@@@ -159,4 +178,4 @@ function message({ heading, text, action }) {
return String(h`<section class="message"><h1>${heading}</h1><p>${text}</p>${action ? h`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}</section>`);
}
--module.exports = { renderBody, blogIndex, collection, postPage, membersTeaser, message, postList, categoryTree, time, dateLabel };
++module.exports = { renderBody, renderMarkdownWithDetails, blogIndex, collection, postPage, membersTeaser, message, postList, categoryTree, time, dateLabel };
ddiff --git a/test/changelog.test.js b/test/changelog.test.js
iindex 4c89183..96642fe 100644
---- a/test/changelog.test.js
++++ b/test/changelog.test.js
@@@ -35,7 +35,10 @@ const H = (n) => n.toString(16).padStart(40, 'a');
return await json({}, 404);
};
const config = { ...t.ctx.config, changelog: { ...t.ctx.config.changelog, enabled: true, batchSize: 5, majorLines: 500, quietMs: 30 * 60 * 1000, maxAgeMs: 7 * 24 * 3600 * 1000, minGapMs: 6 * 3600 * 1000, since: null, aiDraft: false, blogHandle: 'openvibe', history: 0 } };
-- const cl = createChangelog({ config, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, aiDrafts: null, fetchImpl, log: { log() {}, warn() {} } });
++ // Patch notes v2: the changelog files a DRAFT through the patch-notes module and never publishes.
++ const drafts = [];
++ const patchNotes = { draftPending: async ({ entries, reason }) => { drafts.push({ entries, reason }); return { created: true, post: { id: `pst_draft${drafts.length}`, blog_id: 'blg_test' }, checks: { blocking: [], warnings: [] } }; } };
++ const cl = createChangelog({ config, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, aiDrafts: null, patchNotes, fetchImpl, log: { log() {}, warn() {} } });
const commit = (i, msg) => ({ sha: H(i), commit: { message: msg, author: { date: new Date(t.clock.now()).toISOString() } } });
await check('a service seen for the first time only records its release', async () => {
@@@ -55,21 +58,18 @@ const H = (n) => n.toString(16).padStart(40, 'a');
assert.strictEqual(e[0].major, false);
});
-- await check('a major release publishes patch notes after a quiet spell', async () => {
++ await check('a major release after a quiet spell files a Patch notes draft, never a published post', async () => {
releases.tools = 'bbbbbbb00002';
compares.bbbbbbb00002 = { status: 'ahead', commits: [commit(3, 'Recent tools in the launcher: anonymous and account history'), commit(4, 'Tidy the docs')], files: [{ additions: 700, deletions: 20 }] };
assert.strictEqual(await cl.tick(), null, 'major, but deploys are not quiet yet');
t.clock.advance(31 * 60 * 1000);
const r = await cl.tick();
-- assert.ok(r && r.post, 'published');
-- const post = await t.ctx.posts.get(r.post.id);
-- assert.strictEqual(post.state, 'published');
-- const head = await t.ctx.posts.head(post);
-- assert.match(head.fields.title, /^Patch notes: Recent tools in the launcher \([A-Z][a-z]{2} \d{1,2}\)$/, 'the day ends the title');
-- assert.ok(head.content.includes('## Highlights') && head.content.includes('**OpenVibe.Tools:** Recent tools in the launcher'));
-- assert.ok(head.content.includes('## OpenVibe.Live') && head.content.includes('Fix the chat scroll'));
-- assert.strictEqual(head.meta.authorship.mode, 'imported');
-- assert.strictEqual((await cl.entries()).filter((x) => !x.post_id).length, 0, 'every entry now belongs to the post');
++ assert.ok(r && r.post, 'drafted');
++ assert.strictEqual(drafts.length, 1);
++ assert.strictEqual(drafts[0].reason, 'major');
++ assert.deepStrictEqual(drafts[0].entries.map((e) => e.subject).sort(), ['Fix the chat scroll', 'Recent tools in the launcher: anonymous and account history', 'Tidy the docs']);
++ assert.strictEqual(r.post.state, undefined, 'a draft object from the fake; the real module creates a draft post');
++ assert.strictEqual((await cl.entries()).filter((x) => x.post_id).length, 0, 'nothing is published, so the feed has no post link');
});
await check('the public feed names the entries and the latest post', async () => {
@@@ -78,9 +78,8 @@ const H = (n) => n.toString(16).padStart(40, 'a');
assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
const j = r.json();
assert.deepStrictEqual(j.entries.map((x) => x.subject).sort(), ['Recent tools in the launcher: anonymous and account history', 'Tidy the docs']);
-- assert.ok(j.entries.every((x) => x.service === 'tools' && x.post_id));
-- assert.ok(j.latest_post && /\/@openvibe\//.test(j.latest_post.url), 'the latest patch notes post, to link to');
-- assert.match(j.latest_post.title, /^Patch notes: Recent tools in the launcher \([A-Z][a-z]{2} \d{1,2}\)$/);
++ assert.ok(j.entries.every((x) => x.service === 'tools' && x.post_id === null), 'a draft does not link its commits');
++ assert.strictEqual(j.latest_post, null, 'nothing is published from the pipeline');
});
await check('a batch publishes without a major, but never sooner than the gap after the last post', async () => {
@@@ -90,7 +89,8 @@ const H = (n) => n.toString(16).padStart(40, 'a');
t.clock.advance(6 * 3600 * 1000 + 1000);
const r = await cl.tick();
assert.ok(r && r.post);
-- assert.ok((await t.ctx.posts.head(await t.ctx.posts.get(r.post.id))).fields.title.match(/^Patch notes: 5 changes across 1 site \([A-Z][a-z]{2} \d{1,2}\)$/));
++ assert.strictEqual(drafts.length, 2);
++ assert.ok(drafts[1].entries.length >= 5, `all pending, not only the newest five: ${drafts[1].entries.length}`);
});
await check('subjects are cut at a word; version bumps count as major', async () => {
@@@ -106,7 +106,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
await check('history: each site\'s earlier commits once, never pending, with authors; the feed pages by cursor', async () => {
history['OpenVibers/OpenVibe.Live'] = [20, 21, 22].map((i) => ({ sha: H(i), commit: { message: `Earlier change ${i}\n\nbody`, author: { name: 'OpenVibers', date: new Date(t.clock.now() - (30 - i) * 86400000).toISOString() } } }))
.concat([{ sha: H(5), commit: { message: 'Small change 5', author: { name: 'OpenVibers', date: new Date(t.clock.now()).toISOString() } } }]);
-- const hc = createChangelog({ config: { ...config, changelog: { ...config.changelog, history: 50 } }, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, fetchImpl, log: { log() {}, warn() {} } });
++ const hc = createChangelog({ config: { ...config, changelog: { ...config.changelog, history: 50 } }, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, patchNotes, fetchImpl, log: { log() {}, warn() {} } });
const before = (await hc.stats()).pending;
await hc.collect();
assert.strictEqual((await hc.stats()).pending, before, 'history never waits for a patch notes post');
@@@ -123,7 +123,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
assert.ok(p2.entries.length >= 1 && !p2.entries.some((e) => p1.entries.some((x) => x.sha === e.sha)), 'the next page continues without repeats');
const r = (await t.get(`/api/v1/changelog?limit=2&before=${encodeURIComponent(p1.next)}`)).json();
assert.ok(Array.isArray(r.sites) && r.sites.some((x) => x.service === 'live'), 'network-wide answers list the sites');
-- assert.ok(Array.isArray(r.posts) && r.posts.length >= 1);
++ assert.ok(Array.isArray(r.posts), 'the feed lists published posts; the pipeline makes none');
const one = (await t.get('/api/v1/changelog?service=live&limit=1')).json();
assert.strictEqual(one.sites, undefined);
assert.ok(one.next);
@@@ -144,7 +144,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
if (u.hostname === 'api.github.com') seen.auth.push((opts.headers && opts.headers.Authorization) || null);
return await fetchImpl(url, opts);
};
-- const mk = (extra, oauth = { clientId: 'blog', clientSecret: 's3cret' }) => createChangelog({ config: { ...config, oauth, networkInternalUrl: 'http://network.internal', changelog: { ...config.changelog, ...extra } }, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, aiDrafts: null, fetchImpl: fx, log: { log() {}, warn() {} } });
++ const mk = (extra, oauth = { clientId: 'blog', clientSecret: 's3cret' }) => createChangelog({ config: { ...config, oauth, networkInternalUrl: 'http://network.internal', changelog: { ...config.changelog, ...extra } }, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, aiDrafts: null, patchNotes, fetchImpl: fx, log: { log() {}, warn() {} } });
const release = async (cl2, n) => { releases.live = `aaaaaaa1000${n}`; compares[releases.live] = { status: 'ahead', commits: [commit(900 + n, `Token check ${n}`)], files: [{ additions: 1, deletions: 0 }] }; await cl2.collect(); };
const net = mk({ githubToken: '' });
ddiff --git a/test/fixtures/changelog-2026-10-30.json b/test/fixtures/changelog-2026-10-30.json
nnew file mode 100644
iindex 0000000..b0981e5
---- /dev/null
++++ b/test/fixtures/changelog-2026-10-30.json
@@@ -0,0 +1,1205 @@
++{
++ "fetched_at": "2026-10-30T03:10:00Z",
++ "entries": [
++ {
++ "service": "report",
++ "repo": "OpenVibers/OpenVibe.Report",
++ "sha": "1e360e7ce50c90423f4bdf1e530216d5f3c15aff",
++ "short": "1e360e7",
++ "subject": "Touch targets on phones (fleet audit) (#16)",
++ "deployed_at": "2026-10-30T03:04:14.000Z",
++ "committed_at": "2026-10-30T03:04:14Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Report/commit/1e360e7ce50c90423f4bdf1e530216d5f3c15aff"
++ },
++ {
++ "service": "vip",
++ "repo": "OpenVibers/OpenVibe.VIP",
++ "sha": "ce4ea771a693acf7e35909f8f5beff1b207d9df3",
++ "short": "ce4ea77",
++ "subject": "Touch targets on phones (fleet audit) (#124)",
++ "deployed_at": "2026-10-30T03:04:03.000Z",
++ "committed_at": "2026-10-30T03:04:03Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.VIP/commit/ce4ea771a693acf7e35909f8f5beff1b207d9df3"
++ },
++ {
++ "service": "domains",
++ "repo": "OpenVibers/OpenVibe.Domains",
++ "sha": "6cdd0bf9bd53b6a1dd15f8d12dbd97c7237025a4",
++ "short": "6cdd0bf",
++ "subject": "Touch targets on phones (fleet audit) (#17)",
++ "deployed_at": "2026-10-30T03:03:54.000Z",
++ "committed_at": "2026-10-30T03:03:54Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Domains/commit/6cdd0bf9bd53b6a1dd15f8d12dbd97c7237025a4"
++ },
++ {
++ "service": "ai",
++ "repo": "OpenVibers/OpenVibe.AI",
++ "sha": "640f31b1f89a4d49745b3d2c240127bf9df38d82",
++ "short": "640f31b",
++ "subject": "Touch targets on phones (fleet audit) (#199)",
++ "deployed_at": "2026-10-30T03:03:44.000Z",
++ "committed_at": "2026-10-30T03:03:44Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.AI/commit/640f31b1f89a4d49745b3d2c240127bf9df38d82"
++ },
++ {
++ "service": "food",
++ "repo": "OpenVibers/OpenVibe.Food",
++ "sha": "50dcef80cf2eec0c4e6d054fb6936f498a337c72",
++ "short": "50dcef8",
++ "subject": "Touch targets on phones (fleet audit) (#138)",
++ "deployed_at": "2026-10-30T03:03:34.000Z",
++ "committed_at": "2026-10-30T03:03:34Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Food/commit/50dcef80cf2eec0c4e6d054fb6936f498a337c72"
++ },
++ {
++ "service": "reviews",
++ "repo": "OpenVibers/OpenVibe.Reviews",
++ "sha": "fa8b04e9141a104a32101944eb67f4e0698e3cf6",
++ "short": "fa8b04e",
++ "subject": "Touch targets on phones (fleet audit) (#144)",
++ "deployed_at": "2026-10-30T03:03:24.000Z",
++ "committed_at": "2026-10-30T03:03:24Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Reviews/commit/fa8b04e9141a104a32101944eb67f4e0698e3cf6"
++ },
++ {
++ "service": "vision",
++ "repo": "OpenVibers/OpenVibe.Vision",
++ "sha": "fe0cbda8124c7c1761f1ca5ca1be75ef8ce55571",
++ "short": "fe0cbda",
++ "subject": "One sign-in module: openvibe-sdk/sso 0.73.0 (#15)",
++ "deployed_at": "2026-10-30T03:03:10.000Z",
++ "committed_at": "2026-10-30T03:03:10Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Vision/commit/fe0cbda8124c7c1761f1ca5ca1be75ef8ce55571"
++ },
++ {
++ "service": "today",
++ "repo": "OpenVibers/OpenVibe.Today",
++ "sha": "b129731a8673eea73aa1802834eb0c4e5bc3dec7",
++ "short": "b129731",
++ "subject": "One sign-in module: openvibe-sdk/sso 0.73.0 (#15)",
++ "deployed_at": "2026-10-30T02:59:25.000Z",
++ "committed_at": "2026-10-30T02:59:25Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Today/commit/b129731a8673eea73aa1802834eb0c4e5bc3dec7"
++ },
++ {
++ "service": "media-hub",
++ "repo": "OpenVibers/OpenVibe.MediaHub",
++ "sha": "197c1710a86227c70d6d7f8984d47ecf7efb4acd",
++ "short": "197c171",
++ "subject": "Touch targets on phones (fleet audit) (#198)",
++ "deployed_at": "2026-10-30T02:55:17.000Z",
++ "committed_at": "2026-10-30T02:55:17Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.MediaHub/commit/197c1710a86227c70d6d7f8984d47ecf7efb4acd"
++ },
++ {
++ "service": "design",
++ "repo": "OpenVibers/OpenVibe.Design",
++ "sha": "8a976cff4acf507ca6a76bf492b7a55b6f856c57",
++ "short": "8a976cf",
++ "subject": "Touch targets on phones (fleet audit) (#25)",
++ "deployed_at": "2026-10-30T02:53:07.000Z",
++ "committed_at": "2026-10-30T02:53:07Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Design/commit/8a976cff4acf507ca6a76bf492b7a55b6f856c57"
++ },
++ {
++ "service": "host",
++ "repo": "OpenVibers/OpenVibe.Host",
++ "sha": "668f989b3dd047bf0ce3c5f36503fb6fe40883d6",
++ "short": "668f989",
++ "subject": "Touch targets on phones (fleet audit) (#201)",
++ "deployed_at": "2026-10-30T02:52:57.000Z",
++ "committed_at": "2026-10-30T02:52:57Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Host/commit/668f989b3dd047bf0ce3c5f36503fb6fe40883d6"
++ },
++ {
++ "service": "play",
++ "repo": "OpenVibers/OpenVibe.Play",
++ "sha": "13e6600c996927163dd83247333942ab8edb37d7",
++ "short": "13e6600",
++ "subject": "The home page is wider than a phone (Play) (#88)",
++ "deployed_at": "2026-10-30T02:51:14.000Z",
++ "committed_at": "2026-10-30T02:51:14Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Play/commit/13e6600c996927163dd83247333942ab8edb37d7"
++ },
++ {
++ "service": "search",
++ "repo": "OpenVibers/OpenVibe.Search",
++ "sha": "7000fe2c745f4544b2cd2ce1ec067486e2f003cc",
++ "short": "7000fe2",
++ "subject": "The home page is wider than a phone (Search) (#140)",
++ "deployed_at": "2026-10-30T02:51:05.000Z",
++ "committed_at": "2026-10-30T02:51:05Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Search/commit/7000fe2c745f4544b2cd2ce1ec067486e2f003cc"
++ },
++ {
++ "service": "coupons",
++ "repo": "OpenVibers/OpenVibe.Coupons",
++ "sha": "5c7e33720ea0cca908ccb0588f95557c04323f69",
++ "short": "5c7e337",
++ "subject": "The home page is wider than a phone (Coupons) (#144)",
++ "deployed_at": "2026-10-30T02:50:54.000Z",
++ "committed_at": "2026-10-30T02:50:54Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Coupons/commit/5c7e33720ea0cca908ccb0588f95557c04323f69"
++ },
++ {
++ "service": "games",
++ "repo": "OpenVibers/OpenVibe.Games",
++ "sha": "4eee410c1bd4de99a80e97ad669a9a39196c6bc2",
++ "short": "4eee410",
++ "subject": "Touch targets on phones (fleet audit) (#209)",
++ "deployed_at": "2026-10-30T02:50:31.629Z",
++ "committed_at": "2026-10-30T02:46:09Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Games/commit/4eee410c1bd4de99a80e97ad669a9a39196c6bc2"
++ },
++ {
++ "service": "rent",
++ "repo": "OpenVibers/OpenVibe.Rent",
++ "sha": "278c72e0ae44c42b77a3f8f0dc393f8e0ef94e7c",
++ "short": "278c72e",
++ "subject": "Rent: sign-in on openvibe-sdk/sso 0.73.0 (#142)",
++ "deployed_at": "2026-10-30T02:47:05.000Z",
++ "committed_at": "2026-10-30T02:47:05Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Rent/commit/278c72e0ae44c42b77a3f8f0dc393f8e0ef94e7c"
++ },
++ {
++ "service": "fun",
++ "repo": "OpenVibers/OpenVibe.Fun",
++ "sha": "93ded6bb2f80e3d21cfd7b2d454280a1793af061",
++ "short": "93ded6b",
++ "subject": "One sign-in module: openvibe-sdk/sso 0.73.0 (#10)",
++ "deployed_at": "2026-10-30T02:43:56.000Z",
++ "committed_at": "2026-10-30T02:43:56Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Fun/commit/93ded6bb2f80e3d21cfd7b2d454280a1793af061"
++ },
++ {
++ "service": "food",
++ "repo": "OpenVibers/OpenVibe.Food",
++ "sha": "bb1b31fae384ad2019bd826b2ddf5388ad94168b",
++ "short": "bb1b31f",
++ "subject": "One sign-in module: openvibe-sdk/sso 0.73.0 (#139)",
++ "deployed_at": "2026-10-30T02:41:49.000Z",
++ "committed_at": "2026-10-30T02:41:49Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Food/commit/bb1b31fae384ad2019bd826b2ddf5388ad94168b"
++ },
++ {
++ "service": "quest",
++ "repo": "OpenVibers/OpenVibe.Quest",
++ "sha": "ded984082045e117ffd5ddbb46b1a5e1dd8b9cd5",
++ "short": "ded9840",
++ "subject": "Quest: sign-in on openvibe-sdk/sso 0.73.0 (#132)",
++ "deployed_at": "2026-10-30T02:38:12.000Z",
++ "committed_at": "2026-10-30T02:38:12Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Quest/commit/ded984082045e117ffd5ddbb46b1a5e1dd8b9cd5"
++ },
++ {
++ "service": "exchange",
++ "repo": "OpenVibers/OpenVibe.Exchange",
++ "sha": "ea60e42044260ab3f2102739f0174acf025baf61",
++ "short": "ea60e42",
++ "subject": "One sign-in module: openvibe-sdk/sso 0.73.0 (#11)",
++ "deployed_at": "2026-10-30T02:36:00.000Z",
++ "committed_at": "2026-10-30T02:36:00Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Exchange/commit/ea60e42044260ab3f2102739f0174acf025baf61"
++ },
++ {
++ "service": "play",
++ "repo": "OpenVibers/OpenVibe.Play",
++ "sha": "6574d5482b6fd671f780493136891e2b78736c19",
++ "short": "6574d54",
++ "subject": "Touch targets on phones (fleet audit) (#87)",
++ "deployed_at": "2026-10-30T02:34:42.000Z",
++ "committed_at": "2026-10-30T02:34:42Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Play/commit/6574d5482b6fd671f780493136891e2b78736c19"
++ },
++ {
++ "service": "play",
++ "repo": "OpenVibers/OpenVibe.Play",
++ "sha": "c8c139859629205e63c9d5760a389973c8e63274",
++ "short": "c8c1398",
++ "subject": "Play: sign-in on openvibe-sdk/sso 0.73.0 (#86)",
++ "deployed_at": "2026-10-30T02:34:42.000Z",
++ "committed_at": "2026-10-30T02:31:55Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Play/commit/c8c139859629205e63c9d5760a389973c8e63274"
++ },
++ {
++ "service": "work",
++ "repo": "OpenVibers/OpenVibe.Work",
++ "sha": "9c98650464d4e0e0fe524afae32986007c1da620",
++ "short": "9c98650",
++ "subject": "Touch targets on phones (fleet audit) (#124)",
++ "deployed_at": "2026-10-30T02:34:31.000Z",
++ "committed_at": "2026-10-30T02:34:31Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Work/commit/9c98650464d4e0e0fe524afae32986007c1da620"
++ },
++ {
++ "service": "watch",
++ "repo": "OpenVibers/OpenVibe.Watch",
++ "sha": "1f2f8b754c9b03c7efaef7e3c0e832179a0da3b3",
++ "short": "1f2f8b7",
++ "subject": "Touch targets on phones (fleet audit) (#136)",
++ "deployed_at": "2026-10-30T02:34:05.000Z",
++ "committed_at": "2026-10-30T02:34:05Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Watch/commit/1f2f8b754c9b03c7efaef7e3c0e832179a0da3b3"
++ },
++ {
++ "service": "domains",
++ "repo": "OpenVibers/OpenVibe.Domains",
++ "sha": "22830198ec35df8a519d084d89eb71ed94e3394c",
++ "short": "2283019",
++ "subject": "One sign-in module: openvibe-sdk/sso 0.73.0 (#16)",
++ "deployed_at": "2026-10-30T02:33:02.000Z",
++ "committed_at": "2026-10-30T02:33:02Z",
++ "major": false,
++ "post_id": null,
++ "url": "https://github.com/OpenVibers/OpenVibe.Domains/commit/22830198ec35df8a519d084d89eb71ed94e3394c"
++ },
++ {
++ "service": "media-hub",
++ "repo": "OpenVibers/OpenVibe.MediaHub",
++ "sha": "c940b68a240111339645b3d50b3f444d5487294b",
++ "short": "c940b68",
++ "subject": "MediaHub: sign-in on openvibe-sdk/sso 0.73.0 (#197)",
++ "deployed_at": "2026-10-30T02:26:44.000Z",
++ "committed_at": "2026-10-30T02:26:44Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.MediaHub/commit/c940b68a240111339645b3d50b3f444d5487294b"
++ },
++ {
++ "service": "space",
++ "repo": "OpenVibers/OpenVibe.Space",
++ "sha": "741697e56989c98e38b6c33f74443b01fd08ca95",
++ "short": "741697e",
++ "subject": "Pin openvibe-sdk v0.73.0 in the docs (#130)",
++ "deployed_at": "2026-10-30T02:24:15.000Z",
++ "committed_at": "2026-10-30T02:24:15Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Space/commit/741697e56989c98e38b6c33f74443b01fd08ca95"
++ },
++ {
++ "service": "space",
++ "repo": "OpenVibers/OpenVibe.Space",
++ "sha": "ddb99b4bb153ca60023ac4f9456e4a15981fd8a8",
++ "short": "ddb99b4",
++ "subject": "Pin openvibe-sdk v0.73.0 for sign-in recovery and health (#129)",
++ "deployed_at": "2026-10-30T02:24:15.000Z",
++ "committed_at": "2026-10-30T02:16:32Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Space/commit/ddb99b4bb153ca60023ac4f9456e4a15981fd8a8"
++ },
++ {
++ "service": "trade",
++ "repo": "OpenVibers/OpenVibe.Trade",
++ "sha": "1404c5501a807c53790325023650e183f1174e19",
++ "short": "1404c55",
++ "subject": "Pin openvibe-sdk v0.73.0 in the docs (#143)",
++ "deployed_at": "2026-10-30T02:23:45.000Z",
++ "committed_at": "2026-10-30T02:23:45Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Trade/commit/1404c5501a807c53790325023650e183f1174e19"
++ },
++ {
++ "service": "trade",
++ "repo": "OpenVibers/OpenVibe.Trade",
++ "sha": "91d234121e052a63a9368d5a239799aded8c5653",
++ "short": "91d2341",
++ "subject": "Pin openvibe-sdk v0.73.0 for sign-in recovery and health (#142)",
++ "deployed_at": "2026-10-30T02:23:45.000Z",
++ "committed_at": "2026-10-30T02:16:19Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Trade/commit/91d234121e052a63a9368d5a239799aded8c5653"
++ },
++ {
++ "service": "news",
++ "repo": "OpenVibers/OpenVibe.News",
++ "sha": "306f5279274144d763ca4f6326c55c89081c54fb",
++ "short": "306f527",
++ "subject": "Pin openvibe-sdk v0.73.0 in the docs (#143)",
++ "deployed_at": "2026-10-30T02:23:38.000Z",
++ "committed_at": "2026-10-30T02:23:38Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.News/commit/306f5279274144d763ca4f6326c55c89081c54fb"
++ },
++ {
++ "service": "news",
++ "repo": "OpenVibers/OpenVibe.News",
++ "sha": "50bd9278388a8ffcf0db436f53eaa7fc9e217369",
++ "short": "50bd927",
++ "subject": "Pin openvibe-sdk v0.73.0 for sign-in recovery and health (#142)",
++ "deployed_at": "2026-10-30T02:23:38.000Z",
++ "committed_at": "2026-10-30T02:16:09Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.News/commit/50bd9278388a8ffcf0db436f53eaa7fc9e217369"
++ },
++ {
++ "service": "deals",
++ "repo": "OpenVibers/OpenVibe.Deals",
++ "sha": "c1ff289d0377b652cf4f569cb8264f94b9cb3257",
++ "short": "c1ff289",
++ "subject": "Pin openvibe-sdk v0.73.0 in the docs (#143)",
++ "deployed_at": "2026-10-30T02:23:30.000Z",
++ "committed_at": "2026-10-30T02:23:30Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Deals/commit/c1ff289d0377b652cf4f569cb8264f94b9cb3257"
++ },
++ {
++ "service": "deals",
++ "repo": "OpenVibers/OpenVibe.Deals",
++ "sha": "e3e0c185bec68caf8c70ccf69dcdf9fbe28a4c73",
++ "short": "e3e0c18",
++ "subject": "Pin openvibe-sdk v0.73.0 for sign-in recovery and health (#142)",
++ "deployed_at": "2026-10-30T02:23:30.000Z",
++ "committed_at": "2026-10-30T02:15:58Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Deals/commit/e3e0c185bec68caf8c70ccf69dcdf9fbe28a4c73"
++ },
++ {
++ "service": "host",
++ "repo": "OpenVibers/OpenVibe.Host",
++ "sha": "be4918d7883a5d116e69358e0b308bb7a0a4d092",
++ "short": "be4918d",
++ "subject": "Pin openvibe-sdk v0.73.0 for sign-in recovery and health (#200)",
++ "deployed_at": "2026-10-30T02:20:57.000Z",
++ "committed_at": "2026-10-30T02:20:57Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Host/commit/be4918d7883a5d116e69358e0b308bb7a0a4d092"
++ },
++ {
++ "service": "design",
++ "repo": "OpenVibers/OpenVibe.Design",
++ "sha": "695d295577a5f4bfff75f5c41e80432b398ce45c",
++ "short": "695d295",
++ "subject": "One sign-in module: openvibe-sdk/sso 0.73.0 (#24)",
++ "deployed_at": "2026-10-30T02:20:32.000Z",
++ "committed_at": "2026-10-30T02:20:32Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Design/commit/695d295577a5f4bfff75f5c41e80432b398ce45c"
++ },
++ {
++ "service": "codes",
++ "repo": "OpenVibers/OpenVibe.Codes",
++ "sha": "45bf26b4cf257ada0374b68e38a12dbdba321c1e",
++ "short": "45bf26b",
++ "subject": "One sign-in module: openvibe-sdk/sso 0.73.0 (#157)",
++ "deployed_at": "2026-10-30T02:18:05.000Z",
++ "committed_at": "2026-10-30T02:18:05Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Codes/commit/45bf26b4cf257ada0374b68e38a12dbdba321c1e"
++ },
++ {
++ "service": "inventory",
++ "repo": "OpenVibers/OpenVibe.Inventory",
++ "sha": "923626431c117ddde9d60659be537543611c424e",
++ "short": "9236264",
++ "subject": "Inventory: sign-in on openvibe-sdk/sso 0.73.0 (#121)",
++ "deployed_at": "2026-10-30T02:15:27.000Z",
++ "committed_at": "2026-10-30T02:15:27Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Inventory/commit/923626431c117ddde9d60659be537543611c424e"
++ },
++ {
++ "service": "network",
++ "repo": "OpenVibers/OpenVibe.Network",
++ "sha": "ce2dee5cd9e7fa6e4211a7ac7679f8eae2ae4895",
++ "short": "ce2dee5",
++ "subject": "Home on phones: the orbit has room, no label crosses another site (#428)",
++ "deployed_at": "2026-10-30T02:08:37.000Z",
++ "committed_at": "2026-10-30T02:08:37Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Network/commit/ce2dee5cd9e7fa6e4211a7ac7679f8eae2ae4895"
++ },
++ {
++ "service": "coupons",
++ "repo": "OpenVibers/OpenVibe.Coupons",
++ "sha": "5b3de97ca51301bdb490d872b07f854fa3c30dee",
++ "short": "5b3de97",
++ "subject": "Pin openvibe-sdk v0.73.0 for sign-in recovery and health (#143)",
++ "deployed_at": "2026-10-30T01:53:54.000Z",
++ "committed_at": "2026-10-30T01:53:54Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Coupons/commit/5b3de97ca51301bdb490d872b07f854fa3c30dee"
++ },
++ {
++ "service": "blog",
++ "repo": "OpenVibers/OpenVibe.Blog",
++ "sha": "9cfd8a48ca83fab53174f0d1cf44fc0abeb58c99",
++ "short": "9cfd8a4",
++ "subject": "Pin openvibe-sdk v0.73.0 for sign-in recovery and health (#129)",
++ "deployed_at": "2026-10-30T01:45:24.000Z",
++ "committed_at": "2026-10-30T01:45:24Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Blog/commit/9cfd8a48ca83fab53174f0d1cf44fc0abeb58c99"
++ },
++ {
++ "service": "help",
++ "repo": "OpenVibers/OpenVibe.Help",
++ "sha": "47cc706d4407ad052cc23e1af342ce6c00ca2839",
++ "short": "47cc706",
++ "subject": "Help: sign-in on openvibe-sdk/sso 0.73.0 (#136)",
++ "deployed_at": "2026-10-30T01:39:26.000Z",
++ "committed_at": "2026-10-30T01:39:26Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Help/commit/47cc706d4407ad052cc23e1af342ce6c00ca2839"
++ },
++ {
++ "service": "network",
++ "repo": "OpenVibers/OpenVibe.Network",
++ "sha": "6a3b5833b2877612decc32a875142566a48c0d97",
++ "short": "6a3b583",
++ "subject": "Home on touch screens: the orbit's sites stay in place (#427)",
++ "deployed_at": "2026-10-30T01:38:43.000Z",
++ "committed_at": "2026-10-30T01:38:43Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Network/commit/6a3b5833b2877612decc32a875142566a48c0d97"
++ },
++ {
++ "service": "media-hub",
++ "repo": "OpenVibers/OpenVibe.MediaHub",
++ "sha": "18d2becfc9990a2a08783a8ad020290be07e7229",
++ "short": "18d2bec",
++ "subject": "Watch page on touch screens: a list never traps a finger (#196)",
++ "deployed_at": "2026-10-30T01:34:20.000Z",
++ "committed_at": "2026-10-30T01:34:20Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.MediaHub/commit/18d2becfc9990a2a08783a8ad020290be07e7229"
++ },
++ {
++ "service": "meme",
++ "repo": "OpenVibers/OpenVibe.Meme",
++ "sha": "f55f7c67d6266c3d105e0c51eb3d5d7b8a7d7a53",
++ "short": "f55f7c6",
++ "subject": "One sign-in module: openvibe-sdk/sso 0.73.0 (#10)",
++ "deployed_at": "2026-10-30T01:30:04.000Z",
++ "committed_at": "2026-10-30T01:30:04Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Meme/commit/f55f7c67d6266c3d105e0c51eb3d5d7b8a7d7a53"
++ },
++ {
++ "service": "cam",
++ "repo": "OpenVibers/OpenVibe.Cam",
++ "sha": "b561e97e79803ddbf002dec0097ba2d90178f2b0",
++ "short": "b561e97",
++ "subject": "One sign-in module: openvibe-sdk/sso 0.73.0 (#10)",
++ "deployed_at": "2026-10-30T01:27:20.000Z",
++ "committed_at": "2026-10-30T01:27:20Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Cam/commit/b561e97e79803ddbf002dec0097ba2d90178f2b0"
++ },
++ {
++ "service": "chat",
++ "repo": "OpenVibers/OpenVibe.Chat",
++ "sha": "4920e33b53f819bc968d01079fcc503a896ca8fb",
++ "short": "4920e33",
++ "subject": "Touch targets on phones (fleet audit) (#253)",
++ "deployed_at": "2026-10-30T01:08:11.000Z",
++ "committed_at": "2026-10-30T01:08:11Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Chat/commit/4920e33b53f819bc968d01079fcc503a896ca8fb"
++ },
++ {
++ "service": "events",
++ "repo": "OpenVibers/OpenVibe.Events",
++ "sha": "788b0833880ba60ef83e29439336237f8e5fc254",
++ "short": "788b083",
++ "subject": "Touch targets on phones (fleet audit) (#160)",
++ "deployed_at": "2026-10-30T01:01:13.000Z",
++ "committed_at": "2026-10-30T01:01:13Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Events/commit/788b0833880ba60ef83e29439336237f8e5fc254"
++ },
++ {
++ "service": "wiki",
++ "repo": "OpenVibers/OpenVibe.Wiki",
++ "sha": "59aed373df6773c6884a73d44d606697c9c83b74",
++ "short": "59aed37",
++ "subject": "Touch targets on phones (fleet audit) (#152)",
++ "deployed_at": "2026-10-30T01:00:45.000Z",
++ "committed_at": "2026-10-30T01:00:45Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Wiki/commit/59aed373df6773c6884a73d44d606697c9c83b74"
++ },
++ {
++ "service": "news",
++ "repo": "OpenVibers/OpenVibe.News",
++ "sha": "d58f69d35a13e6be44e69d213d25ac99133972b0",
++ "short": "d58f69d",
++ "subject": "Touch targets on phones (fleet audit) (#141)",
++ "deployed_at": "2026-10-30T01:00:12.000Z",
++ "committed_at": "2026-10-30T01:00:12Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.News/commit/d58f69d35a13e6be44e69d213d25ac99133972b0"
++ },
++ {
++ "service": "blog",
++ "repo": "OpenVibers/OpenVibe.Blog",
++ "sha": "2a625c8eb24ed2bb54ab223818b6992062252870",
++ "short": "2a625c8",
++ "subject": "Touch targets on phones (fleet audit) (#128)",
++ "deployed_at": "2026-10-30T00:59:44.000Z",
++ "committed_at": "2026-10-30T00:59:44Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Blog/commit/2a625c8eb24ed2bb54ab223818b6992062252870"
++ },
++ {
++ "service": "deals",
++ "repo": "OpenVibers/OpenVibe.Deals",
++ "sha": "30ee5c48f32315440e76b55cb573bc13c942acf8",
++ "short": "30ee5c4",
++ "subject": "Touch targets on phones (fleet audit) (#141)",
++ "deployed_at": "2026-10-30T00:59:16.000Z",
++ "committed_at": "2026-10-30T00:59:16Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Deals/commit/30ee5c48f32315440e76b55cb573bc13c942acf8"
++ },
++ {
++ "service": "coupons",
++ "repo": "OpenVibers/OpenVibe.Coupons",
++ "sha": "6ffb2da06a29cee5467219694e56c8b192d17e1f",
++ "short": "6ffb2da",
++ "subject": "Touch targets on phones (fleet audit) (#142)",
++ "deployed_at": "2026-10-30T00:58:52.000Z",
++ "committed_at": "2026-10-30T00:58:52Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Coupons/commit/6ffb2da06a29cee5467219694e56c8b192d17e1f"
++ },
++ {
++ "service": "watch",
++ "repo": "OpenVibers/OpenVibe.Watch",
++ "sha": "66ebc8d65a4eb97939fbf461a8ef9696bc97b049",
++ "short": "66ebc8d",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#135)",
++ "deployed_at": "2026-10-30T00:46:32.000Z",
++ "committed_at": "2026-10-30T00:46:32Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Watch/commit/66ebc8d65a4eb97939fbf461a8ef9696bc97b049"
++ },
++ {
++ "service": "tools",
++ "repo": "OpenVibers/OpenVibe.Tools",
++ "sha": "a22bf0b231504e42725f97d2131925364c6079d8",
++ "short": "a22bf0b",
++ "subject": "Touch targets on phones (fleet audit) (#144)",
++ "deployed_at": "2026-10-30T00:46:21.000Z",
++ "committed_at": "2026-10-30T00:46:21Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Tools/commit/a22bf0b231504e42725f97d2131925364c6079d8"
++ },
++ {
++ "service": "vision",
++ "repo": "OpenVibers/OpenVibe.Vision",
++ "sha": "4a6a919d464675f4c573fca178ccbb55bcee8abb",
++ "short": "4a6a919",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#14)",
++ "deployed_at": "2026-10-30T00:46:16.000Z",
++ "committed_at": "2026-10-30T00:46:16Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Vision/commit/4a6a919d464675f4c573fca178ccbb55bcee8abb"
++ },
++ {
++ "service": "vip",
++ "repo": "OpenVibers/OpenVibe.VIP",
++ "sha": "ff48b1b6d8216fdf2c559d71f0ffefd92c39d6f8",
++ "short": "ff48b1b",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#123)",
++ "deployed_at": "2026-10-30T00:46:03.000Z",
++ "committed_at": "2026-10-30T00:46:03Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.VIP/commit/ff48b1b6d8216fdf2c559d71f0ffefd92c39d6f8"
++ },
++ {
++ "service": "today",
++ "repo": "OpenVibers/OpenVibe.Today",
++ "sha": "3b437f73b1727b18a1f8b1e75316a08e0346f8af",
++ "short": "3b437f7",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#13)",
++ "deployed_at": "2026-10-30T00:45:47.000Z",
++ "committed_at": "2026-10-30T00:45:47Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Today/commit/3b437f73b1727b18a1f8b1e75316a08e0346f8af"
++ },
++ {
++ "service": "games",
++ "repo": "OpenVibers/OpenVibe.Games",
++ "sha": "87eb3d258ad1a38deedb8ebde03d88bb23675f23",
++ "short": "87eb3d2",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#208)",
++ "deployed_at": "2026-10-30T00:45:35.116Z",
++ "committed_at": "2026-10-30T00:42:10Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Games/commit/87eb3d258ad1a38deedb8ebde03d88bb23675f23"
++ },
++ {
++ "service": "tips",
++ "repo": "OpenVibers/OpenVibe.Tips",
++ "sha": "f324c4f357205d7f4a0f3d6a1f232d5a12b538b5",
++ "short": "f324c4f",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#125)",
++ "deployed_at": "2026-10-30T00:45:35.000Z",
++ "committed_at": "2026-10-30T00:45:35Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Tips/commit/f324c4f357205d7f4a0f3d6a1f232d5a12b538b5"
++ },
++ {
++ "service": "surf",
++ "repo": "OpenVibers/OpenVibe.Surf",
++ "sha": "bb13989cecd20ed6cb06a91c3a3311192f67aae6",
++ "short": "bb13989",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#9)",
++ "deployed_at": "2026-10-30T00:45:24.000Z",
++ "committed_at": "2026-10-30T00:45:24Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Surf/commit/bb13989cecd20ed6cb06a91c3a3311192f67aae6"
++ },
++ {
++ "service": "space",
++ "repo": "OpenVibers/OpenVibe.Space",
++ "sha": "6adea28c400c7f99e20046ac0b0330c071574ebd",
++ "short": "6adea28",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#127)",
++ "deployed_at": "2026-10-30T00:45:12.000Z",
++ "committed_at": "2026-10-30T00:45:12Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Space/commit/6adea28c400c7f99e20046ac0b0330c071574ebd"
++ },
++ {
++ "service": "sources",
++ "repo": "OpenVibers/OpenVibe.Sources",
++ "sha": "417935295932634e160d7e18bdb4b7e451bf9a6a",
++ "short": "4179352",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#143)",
++ "deployed_at": "2026-10-30T00:44:59.000Z",
++ "committed_at": "2026-10-30T00:44:59Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Sources/commit/417935295932634e160d7e18bdb4b7e451bf9a6a"
++ },
++ {
++ "service": "search",
++ "repo": "OpenVibers/OpenVibe.Search",
++ "sha": "b6a575aca2c2b4f08fa9efad4317908d20409a82",
++ "short": "b6a575a",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#138)",
++ "deployed_at": "2026-10-30T00:44:42.000Z",
++ "committed_at": "2026-10-30T00:44:42Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Search/commit/b6a575aca2c2b4f08fa9efad4317908d20409a82"
++ },
++ {
++ "service": "openre",
++ "repo": "OpenVibers/OpenRestream",
++ "sha": "5fbe6b45994e0d2a4763d878f043ccfda05cd6f9",
++ "short": "5fbe6b4",
++ "subject": "Touch targets on phones (fleet audit) (#144)",
++ "deployed_at": "2026-10-30T00:44:36.000Z",
++ "committed_at": "2026-10-30T00:44:36Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenRestream/commit/5fbe6b45994e0d2a4763d878f043ccfda05cd6f9"
++ },
++ {
++ "service": "run",
++ "repo": "OpenVibers/OpenVibe.Run",
++ "sha": "649df30f1c81dd044110c59816829d6321511c60",
++ "short": "649df30",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#111)",
++ "deployed_at": "2026-10-30T00:44:29.000Z",
++ "committed_at": "2026-10-30T00:44:29Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Run/commit/649df30f1c81dd044110c59816829d6321511c60"
++ },
++ {
++ "service": "run",
++ "repo": "OpenVibers/OpenVibe.Run",
++ "sha": "57bfab389424d67620e2955f963e04621acbca64",
++ "short": "57bfab3",
++ "subject": "Touch targets on phones (fleet audit) (#112)",
++ "deployed_at": "2026-10-30T00:44:29.000Z",
++ "committed_at": "2026-10-30T00:42:41Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Run/commit/57bfab389424d67620e2955f963e04621acbca64"
++ },
++ {
++ "service": "report",
++ "repo": "OpenVibers/OpenVibe.Report",
++ "sha": "101bdc6f9b9b23db36aed16cb44157138cd350bb",
++ "short": "101bdc6",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#15)",
++ "deployed_at": "2026-10-30T00:44:14.000Z",
++ "committed_at": "2026-10-30T00:44:14Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Report/commit/101bdc6f9b9b23db36aed16cb44157138cd350bb"
++ },
++ {
++ "service": "meme",
++ "repo": "OpenVibers/OpenVibe.Meme",
++ "sha": "11abd73b2aa8e05abe7efef2d17162125e4922ca",
++ "short": "11abd73",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#9)",
++ "deployed_at": "2026-10-30T00:43:42.000Z",
++ "committed_at": "2026-10-30T00:43:42Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Meme/commit/11abd73b2aa8e05abe7efef2d17162125e4922ca"
++ },
++ {
++ "service": "network",
++ "repo": "OpenVibers/OpenVibe.Network",
++ "sha": "9eb9b5222305442edfb9ffa4b07ec2ecc7c2fd5a",
++ "short": "9eb9b52",
++ "subject": "Touch targets on phones (fleet audit) (#426)",
++ "deployed_at": "2026-10-30T00:43:34.000Z",
++ "committed_at": "2026-10-30T00:43:34Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Network/commit/9eb9b5222305442edfb9ffa4b07ec2ecc7c2fd5a"
++ },
++ {
++ "service": "tips",
++ "repo": "OpenVibers/OpenVibe.Tips",
++ "sha": "c20437c0db766bd902b9115edcfd583d70da43d7",
++ "short": "c20437c",
++ "subject": "Touch targets on phones (fleet audit) (#126)",
++ "deployed_at": "2026-10-30T00:43:18.000Z",
++ "committed_at": "2026-10-30T00:43:18Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Tips/commit/c20437c0db766bd902b9115edcfd583d70da43d7"
++ },
++ {
++ "service": "help",
++ "repo": "OpenVibers/OpenVibe.Help",
++ "sha": "00b2843ce33f4898647a81986e390535a0e3dfb3",
++ "short": "00b2843",
++ "subject": "Touch targets on phones (fleet audit) (#135)",
++ "deployed_at": "2026-10-30T00:43:09.000Z",
++ "committed_at": "2026-10-30T00:43:09Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Help/commit/00b2843ce33f4898647a81986e390535a0e3dfb3"
++ },
++ {
++ "service": "surf",
++ "repo": "OpenVibers/OpenVibe.Surf",
++ "sha": "c1e5005686fb8f253f0f4c04b24d7fc01d1ffc6f",
++ "short": "c1e5005",
++ "subject": "Touch targets on phones (fleet audit) (#10)",
++ "deployed_at": "2026-10-30T00:43:00.000Z",
++ "committed_at": "2026-10-30T00:43:00Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Surf/commit/c1e5005686fb8f253f0f4c04b24d7fc01d1ffc6f"
++ },
++ {
++ "service": "today",
++ "repo": "OpenVibers/OpenVibe.Today",
++ "sha": "4ddc4df7899436bf1fc15b56b6e5145b2c9a4236",
++ "short": "4ddc4df",
++ "subject": "Touch targets on phones (fleet audit) (#14)",
++ "deployed_at": "2026-10-30T00:42:50.000Z",
++ "committed_at": "2026-10-30T00:42:50Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Today/commit/4ddc4df7899436bf1fc15b56b6e5145b2c9a4236"
++ },
++ {
++ "service": "zone",
++ "repo": "OpenVibers/OpenVibe.Zone",
++ "sha": "7bc980ef65dc4bbe01ce97d598b55c8a0b27573f",
++ "short": "7bc980e",
++ "subject": "Touch targets on phones (fleet audit) (#82)",
++ "deployed_at": "2026-10-30T00:42:31.000Z",
++ "committed_at": "2026-10-30T00:42:31Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Zone/commit/7bc980ef65dc4bbe01ce97d598b55c8a0b27573f"
++ },
++ {
++ "service": "space",
++ "repo": "OpenVibers/OpenVibe.Space",
++ "sha": "07e2544d016060a888f4992723f52ae7b4356a31",
++ "short": "07e2544",
++ "subject": "Touch targets on phones (fleet audit) (#128)",
++ "deployed_at": "2026-10-30T00:42:22.000Z",
++ "committed_at": "2026-10-30T00:42:22Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Space/commit/07e2544d016060a888f4992723f52ae7b4356a31"
++ },
++ {
++ "service": "codes",
++ "repo": "OpenVibers/OpenVibe.Codes",
++ "sha": "791dbf211c96f886e3b0ac92c1115f8b146c26ca",
++ "short": "791dbf2",
++ "subject": "Touch targets on phones (fleet audit) (#156)",
++ "deployed_at": "2026-10-30T00:42:13.000Z",
++ "committed_at": "2026-10-30T00:42:13Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Codes/commit/791dbf211c96f886e3b0ac92c1115f8b146c26ca"
++ },
++ {
++ "service": "fun",
++ "repo": "OpenVibers/OpenVibe.Fun",
++ "sha": "af4e57bbd945b8f894f9d8b1ceb34a8c8bf733b4",
++ "short": "af4e57b",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#9)",
++ "deployed_at": "2026-10-30T00:41:58.000Z",
++ "committed_at": "2026-10-30T00:41:58Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Fun/commit/af4e57bbd945b8f894f9d8b1ceb34a8c8bf733b4"
++ },
++ {
++ "service": "exchange",
++ "repo": "OpenVibers/OpenVibe.Exchange",
++ "sha": "4a221e87abd5ad772ac388d4b47d64691bfaa578",
++ "short": "4a221e8",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#10)",
++ "deployed_at": "2026-10-30T00:41:43.000Z",
++ "committed_at": "2026-10-30T00:41:43Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Exchange/commit/4a221e87abd5ad772ac388d4b47d64691bfaa578"
++ },
++ {
++ "service": "events",
++ "repo": "OpenVibers/OpenVibe.Events",
++ "sha": "e06fc574cb61b9e9cabccb24c6614af221e98d54",
++ "short": "e06fc57",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#159)",
++ "deployed_at": "2026-10-30T00:41:29.000Z",
++ "committed_at": "2026-10-30T00:41:29Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Events/commit/e06fc574cb61b9e9cabccb24c6614af221e98d54"
++ },
++ {
++ "service": "domains",
++ "repo": "OpenVibers/OpenVibe.Domains",
++ "sha": "ee9eeeed59a2f3d8f986925a79214143aef0b3b0",
++ "short": "ee9eeee",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#15)",
++ "deployed_at": "2026-10-30T00:41:17.000Z",
++ "committed_at": "2026-10-30T00:41:17Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Domains/commit/ee9eeeed59a2f3d8f986925a79214143aef0b3b0"
++ },
++ {
++ "service": "trade",
++ "repo": "OpenVibers/OpenVibe.Trade",
++ "sha": "7936d30ac69e168cdc3a9ef723d96fd278161ce3",
++ "short": "7936d30",
++ "subject": "Touch targets on phones (fleet audit) (#141)",
++ "deployed_at": "2026-10-30T00:41:09.000Z",
++ "committed_at": "2026-10-30T00:41:09Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Trade/commit/7936d30ac69e168cdc3a9ef723d96fd278161ce3"
++ },
++ {
++ "service": "chat",
++ "repo": "OpenVibers/OpenVibe.Chat",
++ "sha": "9dcc841648b5da3d17b7528d0e12b3bf1fa2dcfa",
++ "short": "9dcc841",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#252)",
++ "deployed_at": "2026-10-30T00:40:53.000Z",
++ "committed_at": "2026-10-30T00:40:53Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Chat/commit/9dcc841648b5da3d17b7528d0e12b3bf1fa2dcfa"
++ },
++ {
++ "service": "cam",
++ "repo": "OpenVibers/OpenVibe.Cam",
++ "sha": "a65757abc5faa7400cb12d7783424767c7071176",
++ "short": "a65757a",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#9)",
++ "deployed_at": "2026-10-30T00:40:41.000Z",
++ "committed_at": "2026-10-30T00:40:41Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Cam/commit/a65757abc5faa7400cb12d7783424767c7071176"
++ },
++ {
++ "service": "search",
++ "repo": "OpenVibers/OpenVibe.Search",
++ "sha": "8d44a8266b1de6f92c2f26d92d36e42712f69992",
++ "short": "8d44a82",
++ "subject": "Touch targets on phones (fleet audit) (#139)",
++ "deployed_at": "2026-10-30T00:40:28.000Z",
++ "committed_at": "2026-10-30T00:40:28Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Search/commit/8d44a8266b1de6f92c2f26d92d36e42712f69992"
++ },
++ {
++ "service": "bot",
++ "repo": "OpenVibers/OpenVibe.Bot",
++ "sha": "8ae58b3f1064fc5abf0d2d05e2b48ff66a270b09",
++ "short": "8ae58b3",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#228)",
++ "deployed_at": "2026-10-30T00:40:28.000Z",
++ "committed_at": "2026-10-30T00:40:28Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Bot/commit/8ae58b3f1064fc5abf0d2d05e2b48ff66a270b09"
++ },
++ {
++ "service": "billing",
++ "repo": "OpenVibers/OpenVibe.Billing",
++ "sha": "9dbefd0e05e8ae72cc0bca7f2f7958c2e1890575",
++ "short": "9dbefd0",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#132)",
++ "deployed_at": "2026-10-30T00:40:13.000Z",
++ "committed_at": "2026-10-30T00:40:13Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Billing/commit/9dbefd0e05e8ae72cc0bca7f2f7958c2e1890575"
++ },
++ {
++ "service": "bench",
++ "repo": "OpenVibers/OpenVibe.Bench",
++ "sha": "aa91cdb64d5a93af38d2a718b732f82e7cabd950",
++ "short": "aa91cdb",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#12)",
++ "deployed_at": "2026-10-30T00:40:00.000Z",
++ "committed_at": "2026-10-30T00:40:00Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Bench/commit/aa91cdb64d5a93af38d2a718b732f82e7cabd950"
++ },
++ {
++ "service": "ai",
++ "repo": "OpenVibers/OpenVibe.AI",
++ "sha": "1c64bdb304427d185bd85566be13295d6ae707ea",
++ "short": "1c64bdb",
++ "subject": "openvibe-shared v4.45.0 (was v4.43.0) (#198)",
++ "deployed_at": "2026-10-30T00:39:47.000Z",
++ "committed_at": "2026-10-30T00:39:47Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.AI/commit/1c64bdb304427d185bd85566be13295d6ae707ea"
++ },
++ {
++ "service": "design",
++ "repo": "OpenVibers/OpenVibe.Design",
++ "sha": "9cbf75a229000f538a82f92416669a87901fca2d",
++ "short": "9cbf75a",
++ "subject": "Home and API page: comfort settings, compiled native output, the MCP server (#23)",
++ "deployed_at": "2026-10-30T00:38:57.000Z",
++ "committed_at": "2026-10-30T00:38:57Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Design/commit/9cbf75a229000f538a82f92416669a87901fca2d"
++ },
++ {
++ "service": "live",
++ "repo": "OpenVibers/OpenVibe.Live",
++ "sha": "b6b915bd7c57476a90ecbd5a4f74afbc1655a700",
++ "short": "b6b915b",
++ "subject": "Home on touch screens: controls reach 44 px (#453)",
++ "deployed_at": "2026-10-30T00:25:58.000Z",
++ "committed_at": "2026-10-30T00:25:58Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Live/commit/b6b915bd7c57476a90ecbd5a4f74afbc1655a700"
++ },
++ {
++ "service": "tools",
++ "repo": "OpenVibers/OpenVibe.Tools",
++ "sha": "1575175991a70456c4a6d5ca7fb6683bb92fec7a",
++ "short": "1575175",
++ "subject": "Lockfiles: every app matches its package.json again (openvibe-shared v4.43.0) (#142)",
++ "deployed_at": "2026-10-30T00:13:56.000Z",
++ "committed_at": "2026-10-30T00:13:56Z",
++ "major": true,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Tools/commit/1575175991a70456c4a6d5ca7fb6683bb92fec7a"
++ },
++ {
++ "service": "reviews",
++ "repo": "OpenVibers/OpenVibe.Reviews",
++ "sha": "48743cd6817eca1be8c9d6b3a6e662b967196069",
++ "short": "48743cd",
++ "subject": "Home on phones: the scores and ranked lists scroll sideways; one how-it-works fold (#142)",
++ "deployed_at": "2026-10-30T00:13:27.000Z",
++ "committed_at": "2026-10-30T00:13:27Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Reviews/commit/48743cd6817eca1be8c9d6b3a6e662b967196069"
++ },
++ {
++ "service": "design",
++ "repo": "OpenVibers/OpenVibe.Design",
++ "sha": "a21f2c984b46e2b1ecec372d27ae8422c233ff34",
++ "short": "a21f2c9",
++ "subject": "Add public Design MCP tools (#22)",
++ "deployed_at": "2026-10-30T00:12:38.000Z",
++ "committed_at": "2026-10-30T00:12:38Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Design/commit/a21f2c984b46e2b1ecec372d27ae8422c233ff34"
++ },
++ {
++ "service": "network",
++ "repo": "OpenVibers/OpenVibe.Network",
++ "sha": "903147a43257fbea051a1b2141baff401ec70366",
++ "short": "903147a",
++ "subject": "Home on phones: the right-now columns scroll sideways, what signing in adds is a line each (#424)",
++ "deployed_at": "2026-10-30T00:10:29.000Z",
++ "committed_at": "2026-10-30T00:10:29Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Network/commit/903147a43257fbea051a1b2141baff401ec70366"
++ },
++ {
++ "service": "food",
++ "repo": "OpenVibers/OpenVibe.Food",
++ "sha": "c8ff2b1ec0b67bb5919b51ec5172298b2730d646",
++ "short": "c8ff2b1",
++ "subject": "Home on phones: the short lists are sideways rows, every card in them (#136)",
++ "deployed_at": "2026-10-30T00:10:04.000Z",
++ "committed_at": "2026-10-30T00:10:04Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Food/commit/c8ff2b1ec0b67bb5919b51ec5172298b2730d646"
++ },
++ {
++ "service": "vision",
++ "repo": "OpenVibers/OpenVibe.Vision",
++ "sha": "06783aeaeed8b8febb0af27a305bc442c99014a7",
++ "short": "06783ae",
++ "subject": "Vision's events get a durable outbox (#13)",
++ "deployed_at": "2026-10-29T23:59:44.000Z",
++ "committed_at": "2026-10-29T23:59:44Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Vision/commit/06783aeaeed8b8febb0af27a305bc442c99014a7"
++ },
++ {
++ "service": "network",
++ "repo": "OpenVibers/OpenVibe.Network",
++ "sha": "589aebafa283e359a8beb8005b730a7789b68655",
++ "short": "589aeba",
++ "subject": "Keep account comfort display preferences (#423)",
++ "deployed_at": "2026-10-29T23:55:49.000Z",
++ "committed_at": "2026-10-29T23:55:49Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Network/commit/589aebafa283e359a8beb8005b730a7789b68655"
++ },
++ {
++ "service": "design",
++ "repo": "OpenVibers/OpenVibe.Design",
++ "sha": "2dc3be3bb58436f57121f4da9141c1dc3a036c85",
++ "short": "2dc3be3",
++ "subject": "Your look: comfort settings on top of any theme; openvibe-shared v4.45.0 (#21)",
++ "deployed_at": "2026-10-29T23:51:07.000Z",
++ "committed_at": "2026-10-29T23:51:07Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Design/commit/2dc3be3bb58436f57121f4da9141c1dc3a036c85"
++ },
++ {
++ "service": "tools",
++ "repo": "OpenVibers/OpenVibe.Tools",
++ "sha": "88db58ae4710bb092d3702d2cf96cc08b5de1acd",
++ "short": "88db58a",
++ "subject": "Home on phones: the examples scroll sideways, four of the most opened show (#141)",
++ "deployed_at": "2026-10-29T23:35:40.000Z",
++ "committed_at": "2026-10-29T23:35:40Z",
++ "major": false,
++ "post_id": "pst_01M65AA3ESC5K4J4KGJ8TF524P",
++ "url": "https://github.com/OpenVibers/OpenVibe.Tools/commit/88db58ae4710bb092d3702d2cf96cc08b5de1acd"
++ }
++ ]
++}
\\ No newline at end of file
ddiff --git a/test/patch-notes.test.js b/test/patch-notes.test.js
nnew file mode 100644
iindex 0000000..ef58782
---- /dev/null
++++ b/test/patch-notes.test.js
@@@ -0,0 +1,218 @@
++'use strict';
++/**
++ * Patch notes v2: the story map is right on the real 10-29/10-30 batch, the post follows §3.1, and a draft
++ * never publishes without a person and passing checks.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { build } = require('../server/patch-notes/stories');
++const { compose, briefFor, mergeAi, nextItems } = require('../server/patch-notes/draft');
++const { seedCard, OFFICIAL_TEXT } = require('../server/patch-notes/voice');
++const { scan } = require('../server/patch-notes/slop');
++const { evaluate } = require('../server/patch-notes/review');
++const { hasLooks, noLooks, noLooksTags } = require('../server/patch-notes/no-looks');
++const fixture = require('./fixtures/changelog-2026-10-30.json');
++
++const entry = (service, sha, subject, body = null) => ({
++ service, repo: `OpenVibers/OpenVibe.${service}`, sha, subject, message_body: body,
++ committed_at: '2026-10-30T02:00:00.000Z', deployed_at: '2026-10-30T02:00:00.000Z', release: 'r1', major: 0, id: null,
++});
++
++(async () => {
++ // ── the story map, on the real feed ─────────────────────
++ await check('the real 29–30 October batch becomes the four stories the spec names', async () => {
++ const map = build(fixture.entries);
++ const titles = map.stories.slice(0, 4).map((s) => s.title);
++ assert.deepStrictEqual(titles, ['44px, or it doesn\'t count', 'One sign-in, twenty-three sites', 'Homes that scroll sideways', 'The orbit stays put']);
++ const byTitle = Object.fromEntries(map.stories.map((s) => [s.title, s]));
++ assert.strictEqual(byTitle['44px, or it doesn\'t count'].sites.length, 34);
++ assert.strictEqual(byTitle['One sign-in, twenty-three sites'].sites.length, 23);
++ assert.ok(byTitle['The orbit stays put'].entries.length === 2);
++ assert.ok(map.stories.every((s) => s.kind === 'fix' || s.kind === 'feature'));
++ });
++
++ await check('the lockfile round folds into the chores, not into a story', async () => {
++ const map = build(fixture.entries);
++ assert.strictEqual(map.chores.filter((e) => /openvibe-shared v4\./.test(e.subject)).length, 24);
++ assert.strictEqual(map.chores.filter((e) => /in the docs/.test(e.subject)).length, 4);
++ assert.ok(!map.stories.some((s) => /openvibe-shared v4\.45\.0/.test(s.title)), 'no version in a headline');
++ });
++
++ await check('an explicit Workstream trailer wins over the words, and a 72-hour gap starts a new round', async () => {
++ const a = { ...entry('play', 'a'.repeat(40), 'Tidy the launcher'), body: 'Workstream: launcher\nPlayer-facing: yes' };
++ const b = { ...entry('tools', 'b'.repeat(40), 'Tidy the launcher'), body: 'Workstream: launcher\nPlayer-facing: yes' };
++ const c = { ...entry('tools', 'c'.repeat(40), 'Tidy the launcher'), body: 'Workstream: launcher\nPlayer-facing: yes', deployed_at: '2026-11-04T02:00:00.000Z' };
++ const map = build([a, b, c]);
++ assert.ok(map.stories.some((s) => s.key === 'ws:launcher' || s.key.startsWith('ws:')), 'one workstream');
++ assert.strictEqual(map.stories.length, 2, 'the third is three days later: a new round');
++ });
++
++ // ── the template ───────────────────────────────────────
++ await check('the post has a title, a dek, three outcomes, one H2 per story, and the list only under details', async () => {
++ const map = build(fixture.entries);
++ const doc = compose({ map, voice: { card: seedCard() }, pulls: [] });
++ const tw = doc.title.trim().split(/\s+/).length;
++ assert.ok(tw >= 5 && tw <= 9, `title words: ${tw} (${doc.title})`);
++ assert.ok(!/patch notes:/i.test(doc.title));
++ assert.strictEqual((doc.body.match(/^## The short version$/gm) || []).length, 1);
++ const short = doc.body.split('## The short version')[1].split(/^## /m)[0].trim().split('\n').filter((l) => /^- /.test(l));
++ assert.strictEqual(short.length, 3);
++ for (const s of map.stories) assert.ok(doc.body.includes(`## ${s.title}`), `section ${s.title}`);
++ assert.ok(/## Under the hood/.test(doc.body), 'chores get one paragraph');
++ assert.ok(doc.body.includes('<details>') && doc.body.includes('</details>'));
++ assert.ok(doc.body.trim().endsWith('— the OpenVibe crew'));
++ const story = doc.body.split('<details>')[0];
++ assert.ok(!story.includes('Touch targets on phones (fleet audit)'), 'commit subjects stay in the list');
++ assert.ok(!/OpenVibe\.[A-Za-z]+/.test(story), 'no repository names in the story');
++ });
++
++ await check('the AI brief carries the voice card and the story map; the AI body keeps our tail', async () => {
++ const map = build(fixture.entries.slice(0, 60));
++ const brief = briefFor({ map, voice: seedCard(), blog: { handle: 'openvibe' } });
++ assert.ok(brief.includes(OFFICIAL_TEXT.slice(0, 60)));
++ for (const s of map.stories.slice(0, 3)) assert.ok(brief.includes(s.title));
++ const doc = compose({ map, voice: { card: seedCard() }, pulls: [] });
++ const ai = `A short intro.\n\n## The short version\n\n- One thing.\n- Two things.\n- Three things.\n\n## A section\n\nBody.\n\n— the OpenVibe crew`;
++ const merged = mergeAi(ai, doc, seedCard());
++ assert.ok(merged.includes('## A section'));
++ assert.ok(merged.includes('## The full list'));
++ assert.strictEqual((merged.match(/— the OpenVibe crew/g) || []).length, 1, 'one sign-off');
++ });
++
++ await check('What\'s next takes user-facing PRs only, at most three, never a version bump', async () => {
++ const pulls = [
++ { number: 1, title: 'openvibe-shared v4.47.2 (was v4.43.0)', url: 'https://github.com/OpenVibers/OpenVibe.Play/pull/1' },
++ { number: 2, title: 'Phones: a drag carries on into the page', url: 'https://github.com/OpenVibers/OpenVibe.Chat/pull/2' },
++ { number: 3, title: 'One sign-in module', url: 'https://github.com/OpenVibers/OpenVibe.Actor/pull/3' },
++ { number: 4, title: 'Sign in with OpenVibe for developer apps', url: 'https://github.com/OpenVibers/OpenVibe.SDK/pull/4' },
++ { number: 5, title: 'A fourth thing', url: 'https://github.com/OpenVibers/OpenVibe.Tools/pull/5' },
++ ];
++ const picked = nextItems(pulls);
++ assert.strictEqual(picked.length, 3);
++ assert.ok(!picked.some((p) => p.number === 1));
++ assert.ok(!picked.some((p) => p.number === 5));
++ });
++
++ // ── slop and looks ─────────────────────────────────────
++ await check('the slop check blocks the banned phrases and warns on the structures', async () => {
++ const r = scan({ body: 'We leverage a robust, seamless experience.\n\nIt\'s not just a fix, it\'s a transform.\n\nIn conclusion, this is overall a major thing.' });
++ const phrases = r.blocking.map((b) => b.phrase);
++ for (const p of ['leverage', 'robust', 'seamless', 'transform', 'major', 'overall', 'in conclusion']) assert.ok(phrases.includes(p), `blocked ${p}`);
++ assert.ok(r.warnings.some((w) => w.code === 'structure.not_just'));
++ assert.ok(r.warnings.some((w) => w.code === 'structure.restating_conclusion'));
++ assert.strictEqual(scan({ body: 'A plain sentence about a button.\n\nAnother one about a link.' }).blocking.length, 0);
++ });
++
++ await check('the full list is exempt from the phrase check (commit subjects are exact names)', async () => {
++ const body = `## Story\n\nA change.\n\n<details>\n<summary>All</summary>\n\n- Touch targets on phones (fleet audit) (#1)\n\n</details>`;
++ assert.strictEqual(scan({ body }).blocking.length, 0);
++ });
++
++ await check('no looks: a person is described by the role, never by looks; tags follow', async () => {
++ assert.strictEqual(hasLooks('A shirtless man sits in a room.'), true);
++ assert.strictEqual(noLooks('A shirtless man sits in a room with framed pictures on the wall.'), 'The streamer sits in a room with framed pictures on the wall.');
++ assert.strictEqual(noLooks('A man in a gray tank top sits against a plain light background.'), 'A person sits against a plain light background.');
++ assert.deepStrictEqual(noLooksTags(['red-beanie', 'bearded', 'man', 'tanktop', 'hula hoop', 'laptop']), ['hula hoop', 'laptop']);
++ const r = await evaluate({ title: 'A fix', dek: 'A plain dek about the page and what it does for you.', body: 'A shirtless man sits at a desk.', review: null, authorship: { mode: 'human' } });
++ assert.ok(r.blocking.some((b) => b.code === 'looks.person'));
++ });
++
++ // ── the service: a draft, the gates, a person ───────────
++ const t = await boot();
++ const owner = { kind: 'user', subject: t.official.subject, staff: false };
++ const entries = [
++ entry('design', 'd'.repeat(40), 'One sign-in module: openvibe-sdk/sso 0.73.0 (#24)'),
++ entry('codes', 'c'.repeat(40), 'One sign-in module: openvibe-sdk/sso 0.73.0 (#157)'),
++ entry('play', 'e'.repeat(40), 'Touch targets on phones (fleet audit) (#87)'),
++ entry('help', 'f'.repeat(40), 'Touch targets on phones (fleet audit) (#135)'),
++ entry('coupons', 'g'.repeat(40), 'The home page is wider than a phone (Coupons) (#144)'),
++ ];
++
++ await check('the official voice card is seeded with the exact text and a person can edit their own', async () => {
++ const official = await t.ctx.patchNotes.voice.official();
++ assert.strictEqual(official.card.text, OFFICIAL_TEXT);
++ await t.ctx.patchNotes.voice.set(t.official.subject, { text: 'I. First person. I own the bug.', signoff: '— @lead' });
++ const mine = await t.ctx.patchNotes.voice.get(t.official.subject);
++ assert.strictEqual(mine.card.text, 'I. First person. I own the bug.');
++ assert.strictEqual((await t.ctx.patchNotes.voice.official()).card.text, OFFICIAL_TEXT, 'the official card is unchanged');
++ });
++
++ // The commits live in the changelog table (as collect() writes them), so the feed and the marking are real.
++ const db = t.ctx.store.db;
++ let n = 0;
++ for (const e of entries) {
++ await db.prepare(`INSERT INTO changelog_entries (service, repo, sha, subject, author, committed_at, deployed_at, release, release_lines, major, post_id, message_body)
++ VALUES (?, ?, ?, ?, 'OpenVibers', ?, ?, 'r1', 5, 0, NULL, ?)`).run(e.service, e.repo, e.sha, e.subject, e.committed_at, e.deployed_at, e.subject);
++ n++;
++ }
++ assert.strictEqual(n, entries.length);
++
++ let draft = null;
++ await check('a batch becomes a draft, never a published post, and the feed does not point at it', async () => {
++ const rows = await t.ctx.changelog.pendingEntries();
++ const out = await t.ctx.patchNotes.draftPending({ entries: rows, pullRequests: [], useAi: false, reason: 'test' });
++ assert.ok(out.created, JSON.stringify(out));
++ draft = out;
++ const post = await t.ctx.posts.get(out.post.id);
++ assert.strictEqual(post.state, 'draft');
++ const head = await t.ctx.posts.head(post);
++ assert.strictEqual(head.meta.authorship.mode, 'imported');
++ assert.match(head.fields.title, /^Sign-in/);
++ assert.ok(head.content.includes('## The short version'));
++ assert.ok(head.content.includes('## The full list'));
++ const feed = await t.ctx.changelog.entries({ limit: 50 });
++ assert.ok(feed.every((e) => e.post_id === null), 'a draft is not a feed link');
++ const again = await t.ctx.patchNotes.draftPending({ pullRequests: [], useAi: false, reason: 'test' });
++ assert.strictEqual(again.created, false, 'the same commits are not drafted twice');
++ });
++
++ await check('publishing needs a person: no review, no publish', async () => {
++ const post = await t.ctx.posts.get(draft.post.id);
++ await assert.rejects(async () => t.ctx.posts.publish(owner, post, {}), (e) => /review|blocked/i.test(e.message));
++ assert.strictEqual((await t.ctx.posts.get(post.id)).state, 'draft');
++ });
++
++ await check('a banned phrase blocks; its override is recorded and the looks rule still blocks', async () => {
++ let post = await t.ctx.posts.get(draft.post.id);
++ let head = await t.ctx.posts.head(post);
++ await t.ctx.posts.update(owner, post, { body: `${head.content}\n\nWe leverage the new module. A shirtless man sits at a desk.`, expectedRevision: head.number });
++ const c1 = await t.ctx.patchNotes.check(post.id);
++ assert.ok(c1.result.blocking.some((b) => b.code === 'slop.phrase' && b.phrase === 'leverage'));
++ assert.ok(c1.result.blocking.some((b) => b.code === 'looks.person'));
++ await t.ctx.patchNotes.setOverride(post.id, { reason: 'the word is the feature name here', phrases: ['leverage'], by: t.official.subject });
++ const c2 = await t.ctx.patchNotes.check(post.id);
++ assert.ok(!c2.result.blocking.some((b) => b.code === 'slop.phrase'), 'the phrase is overridden');
++ assert.ok(c2.result.warnings.some((w) => w.code === 'slop.overridden'));
++ assert.ok(c2.result.blocking.some((b) => b.code === 'looks.person'), 'looks cannot be overridden');
++ await assert.rejects(async () => t.ctx.posts.publish(owner, await t.ctx.posts.get(post.id), {}), /blocking/i);
++ });
++
++ await check('after the looks sentence goes and a person approves the revision, it publishes', async () => {
++ let post = await t.ctx.posts.get(draft.post.id);
++ let head = await t.ctx.posts.head(post);
++ await t.ctx.posts.update(owner, post, { body: head.content.replace(' A shirtless man sits at a desk.', ''), expectedRevision: head.number });
++ post = await t.ctx.posts.get(post.id);
++ head = await t.ctx.posts.head(post);
++ await t.ctx.posts.review(owner, post, { revision: head.number, decision: 'approved', note: 'checked' });
++ const out = await t.ctx.posts.publish(owner, post, {});
++ assert.ok(out.changed);
++ assert.strictEqual((await t.ctx.posts.get(post.id)).state, 'published');
++ const feed = await t.ctx.changelog.entries({ limit: 50 });
++ assert.ok(feed.some((e) => e.post_id === post.id), 'a published post links its commits');
++ const page = await t.get(`/@openvibe/${post.slug}`);
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('<details class="ov-details">'), 'the full list is a real collapsible block');
++ assert.ok(!page.text.includes('&lt;details&gt;'), 'the block is not escaped text');
++ });
++
++ await check('the editor shows the story map and the checks', async () => {
++ const page = await t.get(`/write/posts/${draft.post.id}`, { as: t.official });
++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
++ assert.ok(page.text.includes('Patch notes review'));
++ assert.ok(page.text.includes('Story map'));
++ assert.ok(page.text.includes('Checklist'));
++ });
++
++ await t.close();
++ done();
++})();