feat(kvb-release): release brief method with article and post channels

kvb-release listens once, writes one brief since the previous tag and hands each channel its slice; kvb-release-article writes the long form and kvb-release-post the short posts, each listening before it brainstorms. A project's own release skill sets file names, title and post structure, and an empty channel list means every channel the project covers. kvb-release-changelog moves its lint steps into lint.md.

🎯 Fix: 92 %
💥 Break: 3 %
💸 Cost: 279 %
⏱️ Time: 100 %
🛡️ Security: 100 %
🏆 Score: 32
🚦 Gate: ⚠️ cost 279 % > 100: operator approved the commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-10 21:13:00 +00:00
1 parent 57ab29eb0d
commit df3cb4e4d6
8 files changed
+238 -12

No files matched your search

+21 -2
View File
@@ -9,7 +9,7 @@ Compatible with Claude Code, Codex, Gemini CLI, Cursor, Copilot, Windsurf, Cline
## Naming 🏷️
Every first-party skill is named `kvb-<category>-<name>` with one of these categories: `think`, `create`, `code`, `ci`, `git`, `release`, `check`, `org`, `agent`, `skill`, `coach`: `think` thinks without producing a file, `create` produces an artifact, `code` works on code, `ci` on pipelines, `git` on history and forges, `release` announces a release (changelog, article, posts), `check` analyses and reports findings, `org` coordinates agents, `agent` acts on one agent itself: its repository setup (code index, agent instructions) and its state (drain, suspend, resume), `skill` works on skills themselves, `coach` serves the operator's wellbeing. Only `kvb-cleanup`, `kvb-coach`, `kvb-help` and the collectors `kvb-dream`, `kvb-optimize` and `kvb-transcend` stand without a category. Names use `a-z`, `0-9` and single hyphens, at most 64 characters, and equal their directory name. `tests/test_naming.py` enforces all of it, including that the organigram below lists every skill under its category.
Every first-party skill is named `kvb-<category>-<name>` with one of these categories: `think`, `create`, `code`, `ci`, `git`, `release`, `check`, `org`, `agent`, `skill`, `coach`: `think` thinks without producing a file, `create` produces an artifact, `code` works on code, `ci` on pipelines, `git` on history and forges, `release` announces a release (changelog, article, posts), `check` analyses and reports findings, `org` coordinates agents, `agent` acts on one agent itself: its repository setup (code index, agent instructions) and its state (drain, suspend, resume), `skill` works on skills themselves, `coach` serves the operator's wellbeing. Only `kvb-cleanup`, `kvb-coach`, `kvb-help` and the collectors `kvb-dream`, `kvb-optimize`, `kvb-release` and `kvb-transcend` stand without a category. Names use `a-z`, `0-9` and single hyphens, at most 64 characters, and equal their directory name. `tests/test_naming.py` enforces all of it, including that the organigram below lists every skill under its category.
## Organigram 🏛️
@@ -58,6 +58,8 @@ flowchart LR
git --> git_branch_gc[kvb-git-branch-gc]
git --> git_orphans_find[kvb-git-orphans-find]
release --> release_changelog[kvb-release-changelog]
release --> release_article[kvb-release-article]
release --> release_post[kvb-release-post]
git --> git_hotfix[kvb-git-hotfix]
git --> git_push[kvb-git-push]
git --> git_diff[kvb-git-diff]
@@ -106,6 +108,7 @@ flowchart LR
none --> transcend_all[kvb-transcend]
none --> dream_all[kvb-dream]
none --> optimize_all[kvb-optimize]
none --> release_all[kvb-release]
none --> coach_all[kvb-coach]
coach --> coach_meditation[kvb-coach-meditation]
```
@@ -162,11 +165,15 @@ flowchart LR
update_branches[kvb-git-update-branches]
branch_gc[kvb-git-branch-gc]
orphans_find[kvb-git-orphans-find]
changelog[kvb-release-changelog]
hotfix[kvb-git-hotfix]
git_push[kvb-git-push]
git_diff[kvb-git-diff]
end
subgraph cat_release [release]
changelog[kvb-release-changelog]
release_article[kvb-release-article]
release_post[kvb-release-post]
end
subgraph cat_check [check]
advocate[kvb-check-legal]
medusa[kvb-check-security]
@@ -220,6 +227,7 @@ flowchart LR
transcend[kvb-transcend]
dream[kvb-dream]
optimize[kvb-optimize]
release_all[kvb-release]
coach[kvb-coach]
end
subgraph cat_coach [coach]
@@ -364,6 +372,17 @@ flowchart LR
autotune --> essence
transcend --> skill_transcend
transcend --> code_transcend
release_all --> changelog
release_all --> release_article
release_all --> release_post
release_all --> active_listening
release_all --> brainstorm
release_article --> active_listening
release_article --> brainstorm
release_post --> active_listening
release_post --> brainstorm
changelog --> active_listening
changelog --> brainstorm
skill_transcend --> active_listening
skill_transcend --> autotune
code_transcend --> active_listening
+45
View File
@@ -0,0 +1,45 @@
---
name: kvb-release-article
description: >
Write a release's long form from its brief: blog article, LinkedIn article,
newsletter.
---
Long form tells one audience why the release matters. Input: `brief.yml` and
`listen.yml` from the `kvb-release` skill, the channel, and the project's
channel facts (voice, length bounds, links, template) when a project skill
passes them. No project facts live here.
## 1. Listen
Run the `kvb-think-listen` skill only for what `listen.yml` leaves open for
this channel, such as the newsletter's audience or its call to action.
## 2. Brainstorm
Run the `kvb-think-brainstorm` skill on this channel's angle and opening hook
for its audience; keep the strongest.
## 3. Write, per language
1. Title: lead with the headline benefit in the reader's words; a project
skill may prefix the version.
2. Lead: what the reader can do now, in two or three sentences.
3. One section per benefit: the effect first, then the proof from its
changelog lines. A benefit the brief cannot prove is left out, never
filled with a placeholder.
4. Upgrade notes, when the brief has any, as plain steps.
5. Close with one call to action and the brief's links.
The audience shapes the words: customers read benefits, developers read the
mechanism and the upgrade path, investors read only what the brief proves.
## 4. Rules
- Every number, name and date comes from `brief.yml`.
- No em dashes, no superlative the brief does not prove.
- Stay inside the channel's length bounds. Without project bounds: a blog
article 600 to 1200 words, a LinkedIn article 400 to 900, a newsletter
250 to 500.
- Write `/tmp/release-<version>/<channel>/<language>.md`, or the format the
project's template names, and report each file's word count.
+8 -10
View File
@@ -11,6 +11,13 @@ finding is unclear, and `special.md` for: no tag, a failed previous-tag
workflow, a test- or CI-only release, no `markdownlint-cli2`, a pasted pkgmgr
rejection.
## 0. Listen, then brainstorm
Run the `kvb-think-listen` skill for what only the operator knows (the tag,
the audience, the highlights, what to leave out), reusing `listen.yml` when
the `kvb-release` skill passed one; then the `kvb-think-brainstorm` skill on
how each change reads for its user, before step 1.
## 1. Find the range
```bash
@@ -60,16 +67,7 @@ empty sections left out.
## 4. Lint it the way pkgmgr does
1. Write `# Changelog`, blank line, `## [X.Y.Z] - <date>`, blank line, the
message to a scratch file outside the repository.
2. From the repository root, so its config applies:
`markdownlint-cli2 --no-globs - < <scratch-file>`.
3. A finding on line N is message line N - 4: shorten it and lint again;
print nothing before 0 errors.
4. Lint a scratch copy of CHANGELOG.md with the wrapped entry inserted below
its `# Changelog` line the same way. A finding in older entries that the
unmodified file lacks comes from the new entry: fix the message, never
the older entries.
Follow `lint.md` next to this file; print nothing before 0 errors.
## 5. Output
+12
View File
@@ -0,0 +1,12 @@
# Lint it the way pkgmgr does
1. Write `# Changelog`, blank line, `## [X.Y.Z] - <date>`, blank line, the
message to a scratch file outside the repository.
2. From the repository root, so its config applies:
`markdownlint-cli2 --no-globs - < <scratch-file>`.
3. A finding on line N is message line N - 4: shorten it and lint again;
print nothing before 0 errors.
4. Lint a scratch copy of CHANGELOG.md with the wrapped entry inserted below
its `# Changelog` line the same way. A finding in older entries that the
unmodified file lacks comes from the new entry: fix the message, never
the older entries.
+48
View File
@@ -0,0 +1,48 @@
---
name: kvb-release-post
description: >
Write a release's short posts from its brief: LinkedIn, Mastodon, WhatsApp,
Instagram.
---
A post earns one click: one hook, one link. Input: `brief.yml`
and `listen.yml` from the `kvb-release` skill, the channel, and the project's
channel facts (voice, hashtags, links, length bounds) when a project skill
passes them. No project facts live here.
## 1. Listen
Run the `kvb-think-listen` skill only for what `listen.yml` leaves open for
this channel, such as the language of a personal message or a mention.
## 2. Brainstorm
Run the `kvb-think-brainstorm` skill on hooks for this channel and audience:
at least three first lines; keep the strongest.
## 3. Write, per language
A project skill's structure (several benefit lines, an "Also new" line)
replaces this one:
1. Hook: the first line carries the headline benefit and works on its own
before "see more".
2. One benefit with its proof from the brief.
3. The brief's link, then the channel's hashtags or mentions.
## 4. Bounds without project facts
| Channel | Limit | Shown before "more" |
| --- | --- | --- |
| LinkedIn post | 3000 characters | about 210 characters |
| Mastodon | 500 characters, a link counts 23 | everything |
| WhatsApp | aim under 700 characters | the first line |
| Instagram caption | 2200 characters, at most 30 hashtags | about 125 characters |
## 5. Rules
- Every number, name and date comes from `brief.yml`.
- No em dashes, no placeholder, no claim the brief does not prove.
- Write into `/tmp/release-<version>/` under the file name the project skill
sets, else `<channel>/<language>.txt`, and report each file's character
count against its limit.
+67
View File
@@ -0,0 +1,67 @@
---
name: kvb-release
description: >
Announce a release: write one brief since the last tag, hand each channel
its slice.
---
One brief per release, read once: every channel writes from the brief, never
from the changelog, so each fact lives in one place. This method holds no
project facts; voice, channels, length bounds, links and templates come from
the project's own `<prefix>-release-<channel>` skills.
## 1. Listen once
Run the `kvb-think-listen` skill once for what only the operator knows: the
tag, the languages (default English), the audiences, the highlights, what to
leave out and the channels wanted. Write the answers to
`/tmp/release-<version>/listen.yml` (`tag`, `languages`, `audience`,
`highlights`, `leave_out`, `channels`) and hand that file to every channel,
so the operator answers each question once.
## 2. Collect
- The range runs from the newest `vX.Y.Z` tag before the release to the
release commit: `git describe --tags --abbrev=0 --match
'v[0-9]*.[0-9]*.[0-9]*' <release tag>^` once the release is tagged, else
the same without `<release tag>^` from HEAD.
- Read the release's changelog entry once; without one, write it first with
the project's changelog skill, else the `kvb-release-changelog` skill.
## 3. Brief
Run the `kvb-think-brainstorm` skill on the headline benefit, then write
`/tmp/release-<version>/brief.yml`: version, date, languages, headline
benefit, three to five benefits each with the changelog lines that prove it,
upgrade notes and links. `languages` is the only answer the brief copies from
`listen.yml`: an answer steers what a channel stresses, it never backs a
number. Every number comes from the changelog or the repository.
## 4. Hand each channel its slice
Per channel in `listen.yml`, or every channel the project's release skills
cover when `listen.yml` names none: the project's
`<prefix>-release-<channel>` skill when its skills directory has one, else
the general method:
| Form | Channels | Method |
| --- | --- | --- |
| long | blog, LinkedIn article, newsletter per audience | `kvb-release-article` |
| short | LinkedIn post, Mastodon, WhatsApp, Instagram | `kvb-release-post` |
| entry | changelog | `kvb-release-changelog` |
Each gets `brief.yml`, `listen.yml` and its channel, runs its own listen and
brainstorm, and writes into `/tmp/release-<version>/` under the file name
the project skill sets, else `<channel>/<language>.<ext>`.
## 5. Check
Fix in every file under `/tmp/release-<version>/`, subfolders included,
before the hand-over: a leftover placeholder (`TODO`, `XXX`, `<...>`), an
em dash, a length outside the channel's bounds, a number absent from
`brief.yml`.
## 6. Hand over
List every file with its channel, language and length. Never publish, post,
send, tag or release: the operator does.
+1
View File
@@ -17,6 +17,7 @@ UNCATEGORIZED = frozenset(
"kvb-dream",
"kvb-help",
"kvb-optimize",
"kvb-release",
"kvb-transcend",
}
)
+36
View File
@@ -0,0 +1,36 @@
"""Pin the operator order of 2026-11-10: every release skill listens, then brainstorms, before it writes."""
from __future__ import annotations
import unittest
from pathlib import Path
SKILLS = Path(__file__).resolve().parents[1] / "skills"
RELEASE = ["kvb-release", "kvb-release-article", "kvb-release-changelog", "kvb-release-post"]
def body(name: str) -> str:
return (SKILLS / name / "SKILL.md").read_text(encoding="utf-8").split("---", 2)[2]
class TestReleaseSkills(unittest.TestCase):
def test_the_release_skills_are_exactly_the_four_methods(self):
self.assertEqual(sorted(path.parent.name for path in SKILLS.glob("kvb-release*/SKILL.md")), RELEASE)
def test_every_release_skill_listens_before_it_brainstorms(self):
for name in RELEASE:
with self.subTest(skill=name):
text = body(name)
listen, brainstorm = text.find("kvb-think-listen"), text.find("kvb-think-brainstorm")
self.assertGreaterEqual(listen, 0)
self.assertGreater(brainstorm, listen)
def test_the_collector_hands_every_form_to_its_method(self):
text = body("kvb-release")
for method in RELEASE[1:]:
with self.subTest(method=method):
self.assertIn(f"`{method}`", text)
if __name__ == "__main__":
unittest.main()