openvibers/openvibe.blog Commits

Patch notes: one-off changes get a line, and the full list is not prose (#134)

OP OpenVibers committed 14 days ago · 87b8c4fbb3 · 5 files, +43 −7

* Patch notes: one-off changes get a line, and the list is not prose

Two fixes found by the first real batch. Unrelated changes with no workstream (a site
switcher, a ported engine) were merged into one section by site count alone; they now get
one "Also shipped" line each, and a batch of only those is still a sensible post. The looks
check read the full list too, so a commit subject about a token cap was blocked as a person:
the list is exempt now while the story, the tail, tags and captions are still checked.

* Patch notes CLI: exit when the draft is filed

The one-off process kept the AI and outbox sockets open after printing, so a run that had
already created the draft never returned. It exits with its status now.

* Patch notes CLI: print the story map and the checklist with the draft

The run that files a draft is also the record a reviewer starts from: it now prints each
workstream (kind, sites, commits) and every checklist line and its detail, not only the
counts.

Changes

ddiff --git a/scripts/patch-notes.js b/scripts/patch-notes.js
iindex ed8ea2f..dd9f4c2 100644
---- a/scripts/patch-notes.js
++++ b/scripts/patch-notes.js
@@@ -35,6 +35,8 @@ const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? arg
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(', ')}` : ''}`);
++ for (const s of out.stories || []) console.log(`story: ${s.title} (${s.kind}, ${s.sites.length} sites, ${s.entries.length} commits)`);
++ for (const c of (out.checks.checklist || [])) console.log(`${c.ok ? 'OK' : 'NO'} ${c.label}${c.detail ? ` — ${c.detail}` : ''}`);
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}`);
@@@ -42,4 +44,6 @@ const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? arg
} finally {
await ctx.store.close();
}
++ // A one-off process: the AI/HTTP keep-alive sockets and the outbox's timers would hold it open.
++ process.exit(process.exitCode || 0);
})();
ddiff --git a/server/patch-notes/draft.js b/server/patch-notes/draft.js
iindex 0f2e0b5..256dd9a 100644
---- a/server/patch-notes/draft.js
++++ b/server/patch-notes/draft.js
@@@ -10,7 +10,7 @@
* 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 { siteLabel, normaliseSubject } = 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' };
@@@ -187,6 +187,15 @@ function whatsNext(pulls) {
return `## What's next\n\n${lines.join('\n')}\n`;
}
++/** The user-visible changes outside the top stories: one line each, no commit subjects. */
++function alsoShipped(also = []) {
++ if (!also.length) return '';
++ const lines = [...also]
++ .sort((a, b) => siteLabel(a).localeCompare(siteLabel(b)) || String(a.subject).localeCompare(String(b.subject)))
++ .map((e) => `- **${siteLabel(e)}:** ${lowerFirst(normaliseSubject(e)).replace(/\.$/, '')}.`);
++ return `## Also shipped\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)));
@@@ -220,9 +229,10 @@ 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');
++ const body = [map.also.length ? alsoShipped(map.also) : 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.',
++ title: map.also.length ? cap(normaliseSubject(map.also[0])) : 'The package versions moved',
++ dek: map.also.length ? 'The smaller changes this round, one line each, with the links below.' : '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'],
};
}
@@@ -233,6 +243,7 @@ function compose({ map, voice, pulls = [] }) {
'## The short version', '',
...shortVersionFor(stories).map((b) => `- ${b}`), '',
...forPost(stories).flatMap((s) => [`## ${s.title}`, '', ...sectionFor(s, {}), '']),
++ alsoShipped(map.also),
underTheHood(map.chores),
whatsNext(pulls),
fullList(map, stories),
@@@ -286,4 +297,4 @@ function mergeAi(aiBody, det, voice) {
return `${body}\n\n${tail}`.replace(/\n{3,}/g, '\n\n').trim();
}
--module.exports = { compose, briefFor, mergeAi, titleFor, dekFor, sectionFor, nextItems, AUDIT };
++module.exports = { compose, briefFor, mergeAi, titleFor, dekFor, sectionFor, alsoShipped, nextItems, AUDIT };
ddiff --git a/server/patch-notes/review.js b/server/patch-notes/review.js
iindex a2849f5..0738c7f 100644
---- a/server/patch-notes/review.js
++++ b/server/patch-notes/review.js
@@@ -19,6 +19,7 @@ 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];
++const withoutDetails = (body) => String(body || '').replace(/<details[\s\S]*?<\/details>/gi, ' ');
function headingsOf(body) {
const out = [];
@@@ -64,7 +65,8 @@ async function evaluate(draft) {
else blocking.push(b);
}
warnings.push(...slop.warnings);
-- const looksText = [title, dek, body, ...tags, ...captions].join('\n');
++ // The full list holds exact commit subjects (a "token cap" is not a hat); the story is what describes people.
++ const looksText = [title, dek, withoutDetails(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' });
ddiff --git a/server/patch-notes/stories.js b/server/patch-notes/stories.js
iindex 0743cf6..87423ef 100644
---- a/server/patch-notes/stories.js
++++ b/server/patch-notes/stories.js
@@@ -20,6 +20,7 @@
const { normaliseSubject, intentOf, siteLabel } = require('./text');
const WINDOW_MS = 72 * 3600 * 1000;
++const baseKeyOf = (key) => String(key || '').split('#')[0];
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'];
@@@ -132,11 +133,14 @@ function scoreOf(group) {
function build(entries, { maxStories = 6 } = {}) {
const groups = mergeSharedChores(mergeByPackage(cluster(entries)));
const stories = [];
++ const also = [];
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; }
++ if (g.kind === 'internal' || baseKeyOf(g.key) === 'outbox') { internal.push(g); continue; }
++ // "Other" is not a workstream: unrelated one-off changes would be merged by site count alone.
++ if (baseKeyOf(g.key) === 'other') { also.push(...g.entries); continue; }
stories.push(g);
}
for (const s of stories) {
@@@ -146,7 +150,7 @@ function build(entries, { maxStories = 6 } = {}) {
}
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);
++ also.push(...stories.slice(maxStories).flatMap((s) => s.entries));
return { stories: top, also, chores: chores.flatMap((g) => g.entries), internal: internal.flatMap((g) => g.entries), groups };
}
ddiff --git a/test/patch-notes.test.js b/test/patch-notes.test.js
iindex ef58782..28f58a0 100644
---- a/test/patch-notes.test.js
++++ b/test/patch-notes.test.js
@@@ -31,6 +31,19 @@ const entry = (service, sha, subject, body = null) => ({
assert.ok(map.stories.every((s) => s.kind === 'fix' || s.kind === 'feature'));
});
++ await check('a one-off change is an Also shipped line, never a section shared with unrelated changes', async () => {
++ const map = build([
++ { ...entry('network', '9'.repeat(40), 'The site switcher follows the account') },
++ { ...entry('codes', '8'.repeat(40), 'engine: the agent engine, ported from the portal') },
++ ]);
++ assert.strictEqual(map.stories.length, 0);
++ assert.strictEqual(map.also.length, 2);
++ const doc = compose({ map, voice: { card: seedCard() }, pulls: [] });
++ assert.ok(!/^## The site switcher/m.test(doc.body));
++ assert.ok(doc.body.includes('## Also shipped'));
++ assert.ok(/agent engine/i.test(doc.body));
++ });
++
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);
@@@ -115,6 +128,8 @@ const entry = (service, sha, subject, body = null) => ({
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'));
++ const ok = await evaluate({ title: 'A fix', dek: 'A plain dek about the page and what it does for you.', body: '## Story\n\nA change.\n\n<details>\n<summary>All</summary>\n\n- Generation: a token cap per showcase kind\n\n</details>', review: null, authorship: { mode: 'human' } });
++ assert.ok(!ok.blocking.some((b) => b.code === 'looks.person'), 'a commit subject in the full list is not a person');
});
// ── the service: a draft, the gates, a person ───────────