diff --git a/README.md b/README.md index 4b5294c..403774d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Compatible with Claude Code, Codex, Gemini CLI, Cursor, Copilot, Windsurf, Cline ## Naming 🏷️ -Every first-party skill is named `kvb--` 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--` 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 diff --git a/skills/kvb-release-article/SKILL.md b/skills/kvb-release-article/SKILL.md new file mode 100644 index 0000000..c2af68c --- /dev/null +++ b/skills/kvb-release-article/SKILL.md @@ -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-//.md`, or the format the + project's template names, and report each file's word count. diff --git a/skills/kvb-release-changelog/SKILL.md b/skills/kvb-release-changelog/SKILL.md index 64cc267..153ebb3 100644 --- a/skills/kvb-release-changelog/SKILL.md +++ b/skills/kvb-release-changelog/SKILL.md @@ -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] - `, blank line, the - message to a scratch file outside the repository. -2. From the repository root, so its config applies: - `markdownlint-cli2 --no-globs - < `. -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 diff --git a/skills/kvb-release-changelog/lint.md b/skills/kvb-release-changelog/lint.md new file mode 100644 index 0000000..63a9220 --- /dev/null +++ b/skills/kvb-release-changelog/lint.md @@ -0,0 +1,12 @@ +# Lint it the way pkgmgr does + +1. Write `# Changelog`, blank line, `## [X.Y.Z] - `, blank line, the + message to a scratch file outside the repository. +2. From the repository root, so its config applies: + `markdownlint-cli2 --no-globs - < `. +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. diff --git a/skills/kvb-release-post/SKILL.md b/skills/kvb-release-post/SKILL.md new file mode 100644 index 0000000..9c1fce4 --- /dev/null +++ b/skills/kvb-release-post/SKILL.md @@ -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-/` under the file name the project skill + sets, else `/.txt`, and report each file's character + count against its limit. diff --git a/skills/kvb-release/SKILL.md b/skills/kvb-release/SKILL.md new file mode 100644 index 0000000..c1008cb --- /dev/null +++ b/skills/kvb-release/SKILL.md @@ -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 `-release-` 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-/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]*' ^` once the release is tagged, else + the same without `^` 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-/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 +`-release-` 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-/` under the file name +the project skill sets, else `/.`. + +## 5. Check + +Fix in every file under `/tmp/release-/`, 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. diff --git a/tests/test_naming.py b/tests/test_naming.py index d906a20..e8734cd 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -17,6 +17,7 @@ UNCATEGORIZED = frozenset( "kvb-dream", "kvb-help", "kvb-optimize", + "kvb-release", "kvb-transcend", } ) diff --git a/tests/test_release_skills.py b/tests/test_release_skills.py new file mode 100644 index 0000000..275313c --- /dev/null +++ b/tests/test_release_skills.py @@ -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()