@natjswenson/devlog 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ ---
2
+ title: "How to turn a folder of Claude Code skills into a discoverable plugin marketplace"
3
+ date: 2026-07-11
4
+ project: ghostwriter
5
+ version: v0.8.1
6
+ tags: [claude-code, plugin-marketplace, skill-md, monorepo, ci, json-lint, developer-tooling]
7
+ summary: "Ghostwriter moved from a manual symlink install to a real Claude Code plugin, which meant learning the marketplace.json/plugin.json schema and one directory-nesting rule that Claude Desktop enforces and the CLI quietly didn't."
8
+ ---
9
+
10
+ ## Shipped
11
+
12
+ Ghostwriter, and three sibling skills in the same repo, switched from a manual "symlink this folder into `~/.claude/skills`" install to a real Claude Code plugin: each skill got a `.claude-plugin/plugin.json`, the repo root got a `.claude-plugin/marketplace.json` listing all four, and install became `/plugin marketplace add` + `/plugin install`. A day later, a follow-up release moved every skill's `SKILL.md` one directory deeper, because the first version worked fine from the command line and silently didn't work in Claude Desktop. That gap, and the two lint scripts written to make sure it can't reopen, is the part worth walking through.
13
+
14
+ ## What a plugin marketplace actually is
15
+
16
+ A marketplace is nothing more than a JSON file. Per the [Claude Code docs](https://code.claude.com/docs/en/plugin-marketplaces), "create `.claude-plugin/marketplace.json` in your repository root. This file defines your marketplace's name, owner information, and a list of plugins with their sources." Each entry needs a `name` and a `source`; for a monorepo, the source is just a relative path:
17
+
18
+ ```json
19
+ {
20
+ "name": "my-tools",
21
+ "owner": { "name": "You" },
22
+ "plugins": [
23
+ { "name": "formatter", "source": "./plugins/formatter" }
24
+ ]
25
+ }
26
+ ```
27
+
28
+ Each plugin listed there is, in turn, its own self-contained directory with a `.claude-plugin/plugin.json`:
29
+
30
+ ```json
31
+ {
32
+ "name": "formatter",
33
+ "version": "1.0.0",
34
+ "description": "Formats code on save"
35
+ }
36
+ ```
37
+
38
+ Once both files exist, `/plugin marketplace add ./my-tools` followed by `/plugin install formatter@my-tools` is the entire install, and `/plugin marketplace update` picks up new pushes. No symlinks, no README with manual copy steps.
39
+
40
+ ## Where the skill's content actually lives
41
+
42
+ This is the part that isn't obvious from the schema alone. The docs describe the skill location as: "**Location**: `skills/` or `commands/` directory in plugin root, or a single `SKILL.md` file at the plugin root," and then add the detail that matters here: "If a plugin has no `skills/` directory and no `skills` manifest field, a `SKILL.md` at the plugin root is loaded as a single skill. Set the frontmatter `name` field to control the skill's invocation name. Without it, Claude Code falls back to the install directory name, which for marketplace-installed plugins is a version string that changes on every update" ([Plugins reference](https://code.claude.com/docs/en/plugins-reference)).
43
+
44
+ So a root-level `SKILL.md` is documented and supported, as long as its frontmatter names itself explicitly. What happened here was a real-world gap between two Claude Code surfaces: the CLI tolerated the four skills' root-level `SKILL.md` files, but Claude Desktop, after installing the same plugin from the same marketplace entry, reported "This plugin doesn't have any skills or agents." The fix was to nest each skill one directory deeper:
45
+
46
+ ```text
47
+ formatter/
48
+ ├── .claude-plugin/
49
+ │ └── plugin.json
50
+ └── skills/
51
+ └── formatter/
52
+ └── SKILL.md
53
+ ```
54
+
55
+ ```markdown
56
+ ---
57
+ name: formatter
58
+ description: Formats the current file
59
+ ---
60
+
61
+ Format the selected file using the project's configured formatter.
62
+ ```
63
+
64
+ That's the layout used above to build the verification scripts below, and it's also what the docs recommend outright for anything beyond a single trivial skill: "For plugins that ship more than one skill, use the `skills/` directory layout." Anthropic's own [official plugin catalog](https://github.com/anthropics/claude-plugins-official) follows the same pattern; its `skill-creator` plugin, for example, nests its skill at `plugins/skill-creator/skills/skill-creator/`, not at the plugin root.
65
+
66
+ ## Build it: a name-consistency lint for one plugin
67
+
68
+ The nesting fix solves the immediate problem, but it opens a new one: now three separate files each carry the plugin's name (`plugin.json`, the directory itself, `SKILL.md`'s frontmatter), and nothing stops them from drifting apart. A rename in one place and not the other two produces a plugin that installs cleanly and then can't be found under the name anyone expects. The fix is a lint script that checks all three agree:
69
+
70
+ ```python
71
+ #!/usr/bin/env python3
72
+ """Checks that a plugin's name stays consistent across its manifest, its
73
+ directory, and its skill's frontmatter."""
74
+ import json
75
+ import os
76
+ import re
77
+ import sys
78
+
79
+
80
+ def parse_skill_name(skill_md_text):
81
+ match = re.search(r'^name:\s*(\S+)', skill_md_text, re.MULTILINE)
82
+ return match.group(1) if match else None
83
+
84
+
85
+ def lint_plugin(plugin_dir):
86
+ errors = []
87
+ dir_name = os.path.basename(os.path.normpath(plugin_dir))
88
+
89
+ plugin_json_path = os.path.join(plugin_dir, ".claude-plugin", "plugin.json")
90
+ with open(plugin_json_path) as fh:
91
+ plugin_data = json.load(fh)
92
+ plugin_name = plugin_data.get("name")
93
+
94
+ if plugin_name != dir_name:
95
+ errors.append(
96
+ f"plugin.json name {plugin_name!r} != directory name {dir_name!r}"
97
+ )
98
+
99
+ skill_md_path = os.path.join(plugin_dir, "skills", dir_name, "SKILL.md")
100
+ if os.path.isfile(skill_md_path):
101
+ with open(skill_md_path) as fh:
102
+ skill_name = parse_skill_name(fh.read())
103
+ if skill_name != plugin_name:
104
+ errors.append(
105
+ f"SKILL.md name {skill_name!r} != plugin.json name {plugin_name!r}"
106
+ )
107
+
108
+ return errors
109
+
110
+
111
+ if __name__ == "__main__":
112
+ errs = lint_plugin(sys.argv[1])
113
+ if errs:
114
+ for e in errs:
115
+ print(f"FAIL: {e}")
116
+ sys.exit(1)
117
+ print("OK")
118
+ ```
119
+
120
+ ## Build it: a membership lint for the marketplace file
121
+
122
+ A single plugin passing its own lint doesn't guarantee the marketplace entry pointing at it is correct. A copy-pasted entry can have the right shape (a `name` and a valid `source`) while pointing at the wrong plugin directory entirely, which a naive "does this name exist somewhere in the plugin set" check won't catch. The check needs to tie each entry's name, its source's directory basename, and that directory's own `plugin.json.name` together as one three-way match, not three independent lookups:
123
+
124
+ ```python
125
+ #!/usr/bin/env python3
126
+ """Checks marketplace.json against the plugin.json files it references:
127
+ every entry resolves, and no entry is cross-wired to the wrong plugin."""
128
+ import glob
129
+ import json
130
+ import os
131
+ import sys
132
+
133
+
134
+ def lint_marketplace(repo_root):
135
+ errors = []
136
+ marketplace_path = os.path.join(repo_root, ".claude-plugin", "marketplace.json")
137
+ with open(marketplace_path) as fh:
138
+ data = json.load(fh)
139
+
140
+ entry_names = set()
141
+ for entry in data.get("plugins", []):
142
+ entry_name = entry["name"]
143
+ source_dir = os.path.normpath(os.path.join(repo_root, entry["source"]))
144
+ source_plugin_json = os.path.join(source_dir, ".claude-plugin", "plugin.json")
145
+
146
+ if not os.path.isfile(source_plugin_json):
147
+ errors.append(f"entry {entry_name!r}: no plugin.json at {entry['source']}")
148
+ continue
149
+
150
+ with open(source_plugin_json) as fh:
151
+ source_name = json.load(fh).get("name")
152
+
153
+ # Per-row three-way tie: catches a cross-wired entry (right shape,
154
+ # wrong target) that a set-membership check alone would miss.
155
+ source_basename = os.path.basename(source_dir)
156
+ if entry_name != source_basename or source_name != entry_name:
157
+ errors.append(
158
+ f"entry {entry_name!r}: basename={source_basename!r}, "
159
+ f"plugin.json.name={source_name!r} -- all three must match"
160
+ )
161
+ entry_names.add(entry_name)
162
+
163
+ dirs_with_plugin_json = {
164
+ os.path.basename(os.path.dirname(os.path.dirname(p)))
165
+ for p in glob.glob(os.path.join(repo_root, "plugins", "*", ".claude-plugin", "plugin.json"))
166
+ }
167
+ orphans = dirs_with_plugin_json - entry_names
168
+ if orphans:
169
+ errors.append(f"plugin directories with no marketplace entry: {sorted(orphans)}")
170
+
171
+ return errors
172
+
173
+
174
+ if __name__ == "__main__":
175
+ errs = lint_marketplace(sys.argv[1])
176
+ if errs:
177
+ for e in errs:
178
+ print(f"FAIL: {e}")
179
+ sys.exit(1)
180
+ print("OK")
181
+ ```
182
+
183
+ ## Use it, then break it on purpose
184
+
185
+ Run both against a correctly wired plugin and marketplace:
186
+
187
+ ```text
188
+ $ python3 lint_plugin.py my-tools/plugins/formatter
189
+ OK
190
+ $ python3 lint_marketplace.py my-tools
191
+ OK
192
+ ```
193
+
194
+ Now rename only `plugin.json`'s `name` field, leaving the directory and `SKILL.md` untouched, the exact drift a careless rename produces:
195
+
196
+ ```text
197
+ $ python3 lint_plugin.py my-tools/plugins/formatter
198
+ FAIL: plugin.json name 'code-formatter' != directory name 'formatter'
199
+ FAIL: SKILL.md name 'formatter' != plugin.json name 'code-formatter'
200
+ $ python3 lint_marketplace.py my-tools
201
+ FAIL: entry 'formatter': basename='formatter', plugin.json.name='code-formatter' -- all three must match
202
+ ```
203
+
204
+ Both scripts catch it immediately, and both point at exactly which file disagrees with which. Wire either one into CI as a pre-merge check and this class of bug never reaches a released plugin.
205
+
206
+ ## Gotchas
207
+
208
+ **A root-level `SKILL.md` worked in the CLI and silently failed in Desktop.** The CLI accepted a plugin whose `SKILL.md` sat directly at the plugin root; Claude Desktop, installing from that same marketplace entry, reported the plugin had no skills or agents at all. Nothing in either surface raised an error at publish time, so the first sign of trouble was a teammate opening Desktop after the marketplace conversion had already shipped. The fix, and the safer default going forward, is the nested `skills/<name>/SKILL.md` layout described above, plus setting the frontmatter `name` explicitly rather than relying on the install-directory fallback.
209
+
210
+ **Nesting the skill one directory deeper broke a test that assumed the old layout.** A version-consistency test read `CHANGELOG.md` relative to the skill's own directory, which worked when `CHANGELOG.md` sat next to `SKILL.md`. After the nesting fix moved `SKILL.md` down a level, `CHANGELOG.md` deliberately stayed at the outer plugin root, so the test's relative path pointed at the wrong location. CI caught it on the same pull request, which is the case for this kind of refactor: a path assumption baked into a test is invisible until something that isn't the test itself moves.
211
+
212
+ ## Sources
213
+
214
+ - [Create and distribute a plugin marketplace](https://code.claude.com/docs/en/plugin-marketplaces) — the marketplace.json/plugin.json schema and the required `name`/`source` fields.
215
+ - [Plugins reference](https://code.claude.com/docs/en/plugins-reference) — the skill discovery rules, including the root-`SKILL.md` fallback and its version-string caveat.
216
+ - [anthropics/claude-plugins-official](https://github.com/anthropics/claude-plugins-official) — confirms the nested `skills/<name>/SKILL.md` layout in a real, official plugin (`skill-creator`).
217
+
218
+ ## Changelog
219
+
220
+ - fix: nest SKILL.md under skills/<name>/ so plugins are discoverable (#32) ([cd9bc5b](https://github.com/natejswenson/claude-skills/commit/cd9bc5b94453c2f70632e1c784e8fcf878dfda7a))
221
+ - feat: convert claude-skills into a Claude Code plugin marketplace (#30) ([7537f10](https://github.com/natejswenson/claude-skills/commit/7537f103c73283a82e5432e99f552d206ccb808c))
@@ -0,0 +1,233 @@
1
+ ---
2
+ title: "Grading a workout without failing every easy day"
3
+ date: 2026-07-20
4
+ project: local-fitness
5
+ version: v0.25.0
6
+ tags: [scoring, rubric-design, product-design, median, robust-statistics, python, llm, pytest]
7
+ summary: "Any system that grades a user against their own history hits the same wall: the naive rubric punishes the behavior you asked for. Four design decisions fix it, and a real graded report card shows what each one is worth."
8
+ ---
9
+
10
+ ## Shipped
11
+
12
+ This release added a graded report card for a single workout. Four metrics, letter
13
+ grades, an overall, and a written read on top. Before it, my fitness agent could
14
+ describe a run but never judge one, so the same workout got called "solid" one day
15
+ and "flat" the next depending on how the model felt.
16
+
17
+ Here is a real card, for a real run:
18
+
19
+ ![A generated report card: overall grade B for a 3.06 mile run, with distance A, pace C, average heart rate A+, and training load A, plus a per-mile table and a heart-rate chart.](/devlog-assets/local-fitness/v0.25.0-report-card.png)
20
+
21
+ The grading is plain Python, not model output. The model gets the four paragraphs
22
+ at the top and is told the letters are not its to revise. That division is the easy
23
+ part to agree with. The hard part is the rubric itself, and the decisions behind it
24
+ transfer to anything that scores a user against their own past: a reading streak,
25
+ a support queue, a spend budget. That is what this post is about, so it stays on
26
+ the decisions rather than walking you through building a fitness app.
27
+
28
+ ## Read the card before reading the code
29
+
30
+ Look at the pace row. The plan prescribed 10:28 per mile; the run came in at 9:28.
31
+ A full minute per mile faster, and it scores a **C** while the heart rate row scores
32
+ an A+.
33
+
34
+ That combination is the entire design problem in one screenshot. A naive rubric
35
+ would have done the opposite on both counts. It would have rewarded running faster,
36
+ because faster is better in almost every system anyone has ever built. And it would
37
+ have punished a heart rate below the runner's norm, because lower is better right
38
+ up until it isn't.
39
+
40
+ Both instincts are wrong here, for the same reason: **the expected behavior was not
41
+ "maximize."** It was "stay easy." Distance from normal is not the same thing as
42
+ failure, and a rubric that cannot tell them apart will fail every deliberate
43
+ deviation you ever asked a user to make.
44
+
45
+ ## Decision 1: collapse every metric to one number
46
+
47
+ The temptation with four metrics is four scoring functions. Resist it. Reduce each
48
+ metric to a single non-negative relative deviation, then push all of them through
49
+ one shared band table.
50
+
51
+ ```python
52
+ GRADE_BANDS = ((0.05, "A"), (0.10, "B"), (0.20, "C"), (0.35, "D"))
53
+
54
+
55
+ def grade_from_deviation(d, widen=1.0):
56
+ """One deviation to one letter. `widen` scales every boundary at once:
57
+ above 1.0 for a loose reference, below 1.0 for an explicit instruction."""
58
+ if d is None:
59
+ return None # ungradeable stays out of the average entirely
60
+ d = max(0.0, float(d))
61
+ for threshold, letter in GRADE_BANDS:
62
+ if d <= threshold * widen + 1e-9:
63
+ return letter
64
+ return "F"
65
+ ```
66
+
67
+ Four deviation functions and one grader means the rubric has exactly one place where
68
+ strictness lives. When a grade looks wrong, you are debugging one table, not four
69
+ scattered thresholds that drifted apart over six months.
70
+
71
+ The `widen` parameter is doing more work than it looks. It is the seam that lets one
72
+ table serve expectations of very different confidence, which turns out to matter a
73
+ lot. More on that in decision 3.
74
+
75
+ `None` propagating through is the other quiet load-bearing bit. A metric you cannot
76
+ grade must be *absent* from the average, not zero. Renormalize by the weights you
77
+ actually used, or a missing heart rate reading silently drags a good workout down.
78
+
79
+ ## Decision 2: gate the deviation by direction
80
+
81
+ This is the one that rescues the easy run, and it is three lines.
82
+
83
+ ```python
84
+ def pace_deviation(actual, expected, intent):
85
+ """Pace is seconds per mile, so LOWER is faster."""
86
+ if intent in ("easy", "long"):
87
+ return max(0.0, (expected - actual) / expected) # only too FAST costs
88
+ if intent == "quality":
89
+ return max(0.0, (actual - expected) / expected) # only too SLOW costs
90
+ return abs(actual / expected - 1.0) # no stated intent
91
+ ```
92
+
93
+ An easy day is penalized for being too fast and never for being too slow. A tempo
94
+ day is the mirror image. Anything without a stated intent falls back to a two-sided
95
+ comparison with deliberately wider bands, because you are guessing.
96
+
97
+ The general form: **before you can grade a deviation you have to know which
98
+ direction was the point.** Most scoring systems skip this because most metrics look
99
+ like "more is better," and then they quietly punish exactly the restraint they were
100
+ built to encourage. If your product ever tells a user to slow down, spend less, or
101
+ close fewer tickets on purpose, an absolute-difference rubric will grade that
102
+ instruction as a failure.
103
+
104
+ Note the same logic on the card's other rows. Distance is one-sided against the
105
+ rolling median, because running longer than usual is never a penalty, but two-sided
106
+ against a plan, because a 12-miler on a 10-mile prescription is overcooking it.
107
+ Heart rate is graded against a *range* rather than a point, which is why its row
108
+ reads "in range" instead of a percentage.
109
+
110
+ ## Decision 3: pick a reference, then say which one you used
111
+
112
+ Every expectation needs a source, and there are only two honest ones: something the
113
+ user was told to do, or something the user typically does. The card names its source
114
+ on every row. Distance and pace here say `plan`; heart rate and load fall back to a
115
+ 60-day rolling median, because the plan has no column for them.
116
+
117
+ Three rules make that fallback trustworthy.
118
+
119
+ **Use the median, not the mean.** Python's own docs are blunt that the mean ["is
120
+ strongly affected by outliers"](https://docs.python.org/3/library/statistics.html)
121
+ while the median ["is a robust measure of central location."](https://docs.python.org/3/library/statistics.html)
122
+ NIST's handbook explains the mechanism in a sentence: ["Extreme values in the tails
123
+ distort the mean. However, these extreme values do not distort the median since the
124
+ median is based on ranks."](https://www.itl.nist.gov/div898/handbook/eda/section3/eda351.htm)
125
+ Every real activity history contains a race, a sensor fault, or a once-a-year
126
+ effort, and each one drags a mean.
127
+
128
+ **Refuse to grade on a thin reference.** Under five comparable activities, the card
129
+ returns n/a and says so. Grading against two data points is worse than not grading,
130
+ because a letter looks equally authoritative either way.
131
+
132
+ **Only compare like with like.** More on that in the gotchas; it is where I got this
133
+ most wrong.
134
+
135
+ And the tightening rule from decision 1 lands here. **A plan target is an
136
+ instruction; a rolling median is a reference.** They are both just an expected value
137
+ in code, which makes it natural to grade them identically, but they carry completely
138
+ different confidence. So plan-referenced bands are scaled by 0.6.
139
+
140
+ You can check that arithmetic against the card. Sixty seconds against a 628-second
141
+ target is a 9.6% deviation. Under the plain bands that sits inside B, near the
142
+ bottom of it; the real grader adds a +/- for position within a band, so it renders
143
+ B-. Tightened for a prescription the same deviation crosses into C, which is what
144
+ the card shows. That one multiplier is the difference between a card that says B-
145
+ and hands out an overall A, and a card that says C, which is the honest verdict for
146
+ a prescribed easy run executed a minute per mile too hot.
147
+
148
+ ## Decision 4: display the number you actually graded against
149
+
150
+ Look at the heart rate row: actual 136, expected "≤ 142 bpm", delta "in range."
151
+
152
+ An earlier version printed the bare rolling median in that column instead. A run at
153
+ 136 against a 146 median rendered as "-7%" sitting next to a B+, when the real
154
+ finding was that it sat 6% *above* the ceiling that produced the grade. Every number
155
+ in the row was individually true and the row as a whole was incoherent.
156
+
157
+ If a metric is judged against a band, the band is what goes in the expected column.
158
+ This is the cheapest correctness check you can build into a scoring UI: **a user
159
+ must be able to recompute your grade from the numbers you showed them.** If they
160
+ can't, the grade reads as a black box no matter how principled the code behind it is.
161
+
162
+ ## Gotchas
163
+
164
+ **Pooling incomparable categories poisons the reference.** My first version pooled
165
+ all running together. Treadmill and road are different heart rate regimes, and on
166
+ live data the mixed pool put median heart rate at 119 against an outdoor average
167
+ near 140, which handed a perfectly normal easy outdoor run a D. The rubric was
168
+ reporting an artifact of the pool, not a judgment. Symptom: a grade that moves when
169
+ unrelated activity is added to the history, with no change to the graded item. The
170
+ escape is exact-category first, widening only when that pool is too thin, and saying
171
+ on the card when it widened.
172
+
173
+ **Bands calibrated on intuition rather than on your distribution.** My original
174
+ easy-heart-rate ceiling was 0.88 of the median, which sounds reasonable and was
175
+ unreachable. The reference median is taken over all comparable activity, and for a
176
+ runner whose training is mostly easy, that median already sits near easy heart rate.
177
+ Demanding 12% below it asked for a number that appeared in 1 of 13 runs in the
178
+ window, and the one that qualified looks like a sensor fault. Heart rate became a
179
+ standing penalty rather than a judgment. Before changing a bound, check what
180
+ fraction of real history clears it. If the answer is "almost none," you wrote a
181
+ constant, not a criterion.
182
+
183
+ **A card that contradicts its own coaching text.** Before the plan-tightening rule,
184
+ this same run scored an overall A while the written read said the runner never ran
185
+ easy at all. Two subsystems, one computing grades and one describing them, disagreeing
186
+ in public. Worth building a check for whenever a generated summary sits next to
187
+ generated numbers.
188
+
189
+ **Grade only what you can grade for everyone.** The per-mile table on the card is
190
+ presentation, not input; no grade reads it. Only 87 of 747 activities in my database
191
+ have per-lap splits, because the daily sync writes them and the historical backfill
192
+ never did. A splits-dependent grade would be unavailable on 88% of history and would
193
+ quietly mean different things on different rows. Same for the heart rate trace under
194
+ the chart, which is fetched on demand for one activity rather than backfilled.
195
+
196
+ **Adding an LLM to a tool will put your test suite on the network.** The read at the
197
+ top of the card is a model call, and every render generates one, so the moment tests
198
+ rendered a card they were making real API calls. The suite went from 10 seconds to 7
199
+ minutes, cost real money, and stayed green throughout, which is why nobody noticed.
200
+ Block it at the choke point with an autouse fixture, the same shape pytest's own docs
201
+ use to remove `requests.sessions.Session.request` so ["any attempts within tests to
202
+ create http requests will fail"](https://docs.pytest.org/en/stable/how-to/monkeypatch.html):
203
+
204
+ ```python
205
+ @pytest.fixture(autouse=True)
206
+ def _no_live_model_calls(monkeypatch):
207
+ """Patch the single SDK entrypoint every generator funnels through, so a
208
+ module added next month inherits this without anyone wiring it up."""
209
+ import my_llm_sdk
210
+
211
+ def _blocked(*args, **kwargs):
212
+ raise RuntimeError("Live model call in a test. Patch the generator.")
213
+
214
+ monkeypatch.setattr(my_llm_sdk, "query", _blocked)
215
+ ```
216
+
217
+ Two things make it work. Patch the one entrypoint rather than each call site. And
218
+ make sure callers degrade to a deterministic fallback, so the raise becomes the
219
+ offline path and the default test run exercises it. An autouse fixture is one that
220
+ [all tests automatically request](https://docs.pytest.org/en/stable/how-to/fixtures.html)
221
+ without naming it, and in a `conftest.py` it covers every test in that directory and
222
+ below.
223
+
224
+ ## Sources
225
+
226
+ - [`statistics` module, Python docs](https://docs.python.org/3/library/statistics.html) — the median as a robust measure of central location versus the mean's sensitivity to outliers.
227
+ - [Measures of location, NIST/SEMATECH e-Handbook of Statistical Methods](https://www.itl.nist.gov/div898/handbook/eda/section3/eda351.htm) — why extreme values distort the mean but not a rank-based statistic.
228
+ - [monkeypatch, pytest docs](https://docs.pytest.org/en/stable/how-to/monkeypatch.html) — the autouse-fixture pattern for blocking remote calls across a suite.
229
+ - [Fixtures, pytest docs](https://docs.pytest.org/en/stable/how-to/fixtures.html) — how `autouse=True` reaches every test in a directory tree.
230
+
231
+ ## Changelog
232
+
233
+ - feat: workout_report_card — graded per-workout report card with coach read and HR/pace chart (0.25.0) (#125) (#126) ([724a1ab](https://github.com/natejswenson/local-fitness/commit/724a1abca6ac276ae891a06a97bd19af4bb8f84f))
@@ -0,0 +1,129 @@
1
+ ---
2
+ title: "How to tell which package in a monorepo actually needs a release"
3
+ date: 2026-07-11
4
+ project: resume
5
+ version: v1.0.1
6
+ tags: [git, monorepo, semver, release-engineering, tagging, path-filter, ci-cd]
7
+ summary: "Two sibling packages in the same repo both got a patch release from the exact same commit. Here's the git-tag-plus-path-filter technique that tells you which packages actually need a release when one commit touches all of them."
8
+ ---
9
+
10
+ ## Shipped
11
+
12
+ Four sibling skills in one repo all got a `.claude-plugin/plugin.json` and a `CHANGELOG.md` entry in the same two commits, part of [converting the whole repo into a Claude Code plugin marketplace](https://code.claude.com/docs/en/plugin-marketplaces). Two of those four, resume and a sibling skill, immediately got a real git release tag cut for it (`resume-v1.0.1`); the other two didn't, at least not yet. That split is the actual topic here: how do you decide, mechanically, which package in a monorepo genuinely needs a release when one commit touches several of them at once?
13
+
14
+ ## The problem with one shared commit
15
+
16
+ A monorepo with several independently versioned packages runs into this the first time an infrastructure change lands in one commit that legitimately touches every package: a CI migration, a lint rule, a shared build script. Every package's code changed, so does every package deserve a new release? A single repo-wide version number sidesteps the question by making everything one release, but that has its own cost. As one team's writeup on this puts it plainly: with a unified version "we would have to 'build the world' every time there is an update to the repo," which stopped being feasible once "some of the components take >15min to build" ([Streamdal: Monorepos: Version, Tag, and Release Strategy](https://streamdal.com/blog/monorepos-version-tag-and-release-strategy/)). Per-package versioning avoids that, but only if there's a mechanical way to tell "this package's code changed" from "this package needs a new tag."
17
+
18
+ ## Build it: a tag prefix per package
19
+
20
+ The standard fix, and the one used here, is a distinct tag prefix per package instead of one shared version:
21
+
22
+ ```text
23
+ resume-v1.0.0
24
+ resume-v1.0.1
25
+ ghostwriter-v0.8.0
26
+ ghostwriter-v0.8.1
27
+ ```
28
+
29
+ Per the same writeup, this "is not valid semantic versioning but it is a common approach to tagging multiple components that live within the same repository." It buys three things a single repo version can't: only the changed package needs its build/release pipeline to run, tag volume stays proportional to actual releases per package instead of exploding with every commit, and `git tag -l 'resume-v*'` alone answers "what has resume shipped" without touching any other package's history.
30
+
31
+ ## Build it: deciding when a shared commit earns a package its own tag
32
+
33
+ A prefix alone doesn't answer the actual question, though: given a commit that touches multiple packages, which of those packages' tag prefixes should include it? The rule that works is a path filter checked against the commit range since that package's last tag, not against the commit in isolation:
34
+
35
+ ```python
36
+ #!/usr/bin/env python3
37
+ """Finds git tags that represent a real release for one package in a
38
+ monorepo: a tag matching that package's prefix, with commits since the
39
+ previous matching tag that actually touch that package's path."""
40
+ import subprocess
41
+ import sys
42
+
43
+
44
+ def run(*args):
45
+ return subprocess.run(
46
+ ["git", *args], cwd=sys.argv[1], capture_output=True, text=True, check=True
47
+ ).stdout.strip()
48
+
49
+
50
+ def tags_for_prefix(prefix):
51
+ all_tags = run("tag", "--sort=creatordate").splitlines()
52
+ return [t for t in all_tags if t.startswith(prefix)]
53
+
54
+
55
+ def find_releases(prefix, path_filter):
56
+ tags = tags_for_prefix(prefix)
57
+ releases = []
58
+ for i, tag in enumerate(tags):
59
+ prev_tag = tags[i - 1] if i > 0 else None
60
+ commit_range = f"{prev_tag}..{tag}" if prev_tag else tag
61
+ commits = run("log", "--oneline", commit_range, "--", path_filter)
62
+ if commits:
63
+ releases.append((tag, prev_tag, commits.splitlines()))
64
+ return releases
65
+
66
+
67
+ if __name__ == "__main__":
68
+ prefix, path_filter = sys.argv[2], sys.argv[3]
69
+ for tag, prev_tag, commits in find_releases(prefix, path_filter):
70
+ print(f"{tag} (since {prev_tag or 'repo start'}): {len(commits)} commit(s)")
71
+ for c in commits:
72
+ print(f" {c}")
73
+ ```
74
+
75
+ The `-- <path_filter>` on `git log` is what makes this a per-package answer instead of a repo-wide one: it only returns commits in that range that touched files under the package's own directory, silently dropping commits that only touched other packages.
76
+
77
+ ## Use it, then verify it against a real shared commit
78
+
79
+ To see this handle the actual scenario, a two-package repo with a shared infrastructure commit at the end:
80
+
81
+ ```text
82
+ $ git log --oneline --all
83
+ c96fcda feat: convert all packages into plugin-installable format
84
+ 50e7884 feat: initial api and worker
85
+ dcb1bb6 feat(api): add health check endpoint
86
+ $ git tag
87
+ api-v1.0.0
88
+ api-v1.1.0
89
+ api-v1.2.0
90
+ worker-v1.0.0
91
+ worker-v1.1.0
92
+ ```
93
+
94
+ `api-v1.2.0` and `worker-v1.1.0` were both tagged at `c96fcda`, the shared commit. Running the script for each package:
95
+
96
+ ```text
97
+ $ python3 find_releases.py . api-v packages/api
98
+ api-v1.0.0 (since repo start): 1 commit(s)
99
+ 50e7884 feat: initial api and worker
100
+ api-v1.1.0 (since api-v1.0.0): 1 commit(s)
101
+ dcb1bb6 feat(api): add health check endpoint
102
+ api-v1.2.0 (since api-v1.1.0): 1 commit(s)
103
+ c96fcda feat: convert all packages into plugin-installable format
104
+
105
+ $ python3 find_releases.py . worker-v packages/worker
106
+ worker-v1.0.0 (since repo start): 1 commit(s)
107
+ 50e7884 feat: initial api and worker
108
+ worker-v1.1.0 (since worker-v1.0.0): 1 commit(s)
109
+ c96fcda feat: convert all packages into plugin-installable format
110
+ ```
111
+
112
+ The shared commit `c96fcda` correctly shows up as the release-triggering commit for both packages, each under its own path filter, while `dcb1bb6` (an api-only commit) never appears in worker's list at all. That's the whole answer: a package gets a release when its own path filter finds real commits in the range, whether or not other packages' commits happen to share the same hash.
113
+
114
+ ## Gotchas
115
+
116
+ **A version bump in a manifest file is not the same signal as a release tag.** In the run that prompted this, four sibling packages all got their `plugin.json` version field and `CHANGELOG.md` bumped in the exact same commit. Two of them also got a fresh git tag cut for that bump; two didn't, at least not at the time of writing. Anything downstream that keys off "which packages were released" by scanning git tags, the way the script above does, would miss those two entirely, even though their manifest files genuinely changed. If a release-detection process reads tags, a manifest bump with no matching tag is invisible to it, whether that's an oversight or a deliberate choice to hold that package's release for later. Either way, don't assume a changed version field implies a cut release; check the tag. It's the same risk the [npm blog's monorepo writeup](https://blog.npmjs.org/post/186494959890/monorepos-and-npm.html) names directly: "asking humans to remember to do this across a large collection of packages... is asking for trouble." A tag-scanning script doesn't get to forget; a human bumping four manifest files by hand can, and evidently did for two of them.
117
+
118
+ **A shared commit read as duplicate content the first time through, not as two separate release stories.** Writing this pair of posts, the two package's commit ranges resolved to the exact same two commits, because that's genuinely what happened. The first pass toward the two writeups almost retold the whole marketplace-conversion story twice. The fix was deciding up front which package's post owns the full narrative and giving the other one a genuinely different angle instead, the technique this post covers, rather than two copies of the same walkthrough with the package name swapped.
119
+
120
+ ## Sources
121
+
122
+ - [Streamdal: Monorepos: Version, Tag, and Release Strategy](https://streamdal.com/blog/monorepos-version-tag-and-release-strategy/) — the per-package tag-prefix pattern and why a single repo-wide version breaks down.
123
+ - [npm Blog Archive: Monorepos and npm](https://blog.npmjs.org/post/186494959890/monorepos-and-npm.html) — on the risk of lockstep versioning: "asking humans to remember to do this across a large collection of packages... is asking for trouble," the human-error case for a mechanical check over a manual convention.
124
+ - [Create and distribute a plugin marketplace](https://code.claude.com/docs/en/plugin-marketplaces) — background on the plugin conversion that produced the shared commit this post's example is drawn from.
125
+
126
+ ## Changelog
127
+
128
+ - fix: nest SKILL.md under skills/<name>/ so plugins are discoverable (#32) ([cd9bc5b](https://github.com/natejswenson/claude-skills/commit/cd9bc5b94453c2f70632e1c784e8fcf878dfda7a))
129
+ - feat: convert claude-skills into a Claude Code plugin marketplace (#30) ([7537f10](https://github.com/natejswenson/claude-skills/commit/7537f103c73283a82e5432e99f552d206ccb808c))
@@ -34,6 +34,8 @@ function validateManifest(data) {
34
34
  const entries = [];
35
35
  for (const e of data.entries) {
36
36
  if (!e || typeof e !== 'object') continue;
37
+ // Tombstoned rows (removed: true) are editorial retirements, not entries.
38
+ if (e.removed) continue;
37
39
  const { date, file, title, summary, version } = e;
38
40
  if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
39
41
  if (typeof file !== 'string' || !/^[a-zA-Z0-9._-]+\.md$/.test(file)) continue;