@natjswenson/devlog 0.5.1 → 0.6.0

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.
package/SKILL.md CHANGED
@@ -109,30 +109,55 @@ For each new release, in order:
109
109
 
110
110
  **3a. Understand what actually shipped.** The scan gives you commit subjects and a
111
111
  diffstat. When you need more, read the real changes — validate every hash matches
112
- `^[0-9a-f]{7,40}$` first, then:
112
+ `^[0-9a-f]{7,40}$` first, then (when a commit message and its diff disagree on a
113
+ specific fact — a count, a filename, a behavior — the diff wins):
113
114
 
114
115
  ```bash
115
116
  git -C '<project.path>' show --stat '<hash>'
116
117
  git -C '<project.path>' show '<hash>' -- '<project.pathFilter>'
117
118
  ```
118
119
 
120
+ Teach the code **as it existed at this tag**, not as it looks today — the repo may have
121
+ moved on since the release. When you need a file's state rather than a diff, use
122
+ `git -C '<project.path>' show '<tag>:<file>'` instead of reading the working tree.
123
+ This anchors the **facts** (what shipped, what it did); the teaching implementation may
124
+ still be a cleaner generalization per the how-to contract — anchor claims at the tag,
125
+ generalize the code.
126
+
119
127
  **3b. Derive the topic.** Identify the **one** substantive engineering topic the work
120
128
  touched (occasionally more, only when the work genuinely spans them) within
121
129
  `deepDive.topicDomains`. The topic is the general concept *behind* what shipped (e.g.
122
130
  shipping a `feature→dev→main` flow → branching strategy and release engineering). Never
123
131
  pad with topics the work didn't touch.
124
132
 
133
+ Between candidate topics, pick the one the reader can most plausibly **use**: a
134
+ transferable technique they could apply to their own project this week beats project
135
+ trivia or niche internals. If the obvious topic is repo-specific, step up one level to
136
+ the general pattern behind it — the test is "could a reader finish this how-to and have
137
+ something working of their own?" When a release spans two candidate topics (or two
138
+ releases in one run share one), don't write the same guide twice: give each post the
139
+ most usable topic the run hasn't already covered. In a monorepo, one commit can appear
140
+ in several projects' ranges — it belongs to the post whose release story it is; other
141
+ posts leave it out of their narrative and `## Changelog` (check what sibling entries
142
+ already cover, across all projects).
143
+
125
144
  **3c. Research before writing.** Use web search/fetch to gather at least
126
145
  `deepDive.minSources` **distinct** reputable sources: official docs and release notes,
127
146
  standards bodies, primary research, well-regarded engineering writing. Avoid SEO farms.
128
147
  Every specific external claim (a version, a behavior, a study, a definition) must be
129
148
  backed by a source you actually verified — if you can't source it, don't claim it. Don't
130
- lean on one URL for most claims. Keep a working `(claim, url)` list.
149
+ lean on one URL for most claims. Keep a working `(claim, url)` list. Fetch tools can
150
+ summarize a page into quote-shaped text that isn't on it: before putting anything in
151
+ quotation marks, re-fetch asking for the verbatim wording and drop the quotes (paraphrase
152
+ instead) if it can't be confirmed.
131
153
 
132
154
  **3d. Mine the gotchas.** Gotchas are the post's signature — **real traps from the
133
155
  user's own experience**, never invented. Look for them in: fix commits that follow the
134
156
  feature commits in the range, revert commits, `CHANGELOG` "Fixed" entries for this
135
- version, and corrections visible in the diffs (an approach that changed mid-range). Each
157
+ version, and corrections visible in the diffs (an approach that changed mid-range).
158
+ Also follow the code this release introduced **forward** in history (`git log '<tag>..'
159
+ -- <files it touched>`): a later fix to that same code is prime gotcha material, as long
160
+ as the post says plainly when it was discovered ("this bit us a few weeks later"). Each
136
161
  gotcha is written as **trap → symptom → escape**, concretely. If the history genuinely
137
162
  shows none, the `## Gotchas` section instead covers the sourced failure modes a reader
138
163
  will hit first, clearly framed as "what to watch for" rather than as personal war stories.
@@ -141,7 +166,7 @@ will hit first, clearly framed as "what to watch for" rather than as personal wa
141
166
 
142
167
  ```markdown
143
168
  ---
144
- title: "<essay-style title; NOT 'release vX.Y.Z'>"
169
+ title: "<essay-style title in sentence case (capitalize only the first word and proper nouns); NOT 'release vX.Y.Z'>"
145
170
  date: <release date from scan>
146
171
  project: <project key>
147
172
  version: <version from scan>
@@ -154,6 +179,11 @@ summary: "<1-2 sentence hook that frames the how-to, not just what shipped>"
154
179
  <2-4 sentences: what this release delivered, plainly, then pivot to the topic the guide
155
180
  teaches. The only purely-changelog part.>
156
181
 
182
+ <!-- Between Shipped and Gotchas, the headings below are the default shape, not a
183
+ requirement — only Shipped, Gotchas, and Sources are mandatory. Merge or reorder the
184
+ middle sections when the walkthrough flows better that way. Fence command OUTPUT
185
+ blocks as `text`. -->
186
+
157
187
  ## <Descriptive heading: setup / prerequisites>
158
188
 
159
189
  <What a reader needs before the core build: dependencies, config, data model. Code block
@@ -189,11 +219,17 @@ the wiring between pieces, not just the interesting line.>
189
219
  2. **Complete code.** Every symbol a code block references is defined in an earlier
190
220
  block or explicitly stubbed with a one-line note ("`load_fixtures()` returns your test
191
221
  DB handle"). The blocks compose into a runnable whole — no phantom fixtures or elided
192
- helpers. Aim for the essential blocks (roughly 3-6 for a substantive feature); a clean,
222
+ helpers, and if a later block revises an earlier function, show the complete new
223
+ function, never a fragment calling helpers no block defines. Aim for the essential blocks (roughly 3-6 for a substantive feature); a clean,
193
224
  general version of the concept is the goal, and never claim illustrative code is
194
225
  verbatim production source.
195
226
  3. **Reader-side verification.** The verify step gives commands the READER runs against
196
227
  THEIR implementation, with expected output — not proof that the author's repo works.
228
+ When the blocks are cheap to execute (scratch dir, no external services), actually
229
+ run them and paste the real output. Never present output as observed if you didn't
230
+ run the command; if you can't run it, frame the expectation ("you should see…").
231
+ Verbose real output (tracebacks, long logs) may be trimmed to the signal lines or
232
+ whitespace-normalized when the post says so.
197
233
  4. **Real gotchas** per 3d.
198
234
  5. **Source diversity** per 3c, cited inline as markdown links AND in `## Sources`.
199
235
  6. **Honest scope.** A single test file is not "end-to-end". Size the title, summary, and
@@ -202,12 +238,21 @@ the wiring between pieces, not just the interesting line.>
202
238
  teaser.
203
239
  7. **No leaked repo-specific artifacts.** Genericize or explain anything a stranger
204
240
  would trip on (`.example` suffixes, monorepo nesting, internal tool names).
241
+ 8. **Fun to follow.** The guide reads like a generous colleague walking the reader
242
+ through a build they'll actually finish: give an early runnable win, keep momentum
243
+ between steps, and make the payoff visible at each stage (show real output, not just
244
+ code). Fun comes from quick wins and concrete results — never forced jokes, hype, or
245
+ exclamation points.
205
246
 
206
247
  **Separate fact from concept.** What the user *did* comes only from commits/diffs —
207
248
  never invent metrics, motivations, or outcomes. What the topic *is* comes from the cited
208
249
  sources. Keep the two distinguishable. Match the voice profile + `voice-notes.md`
209
- (authenticity, anti-AI-tell, and punctuation rules), but NOT any length/reach rules
210
- target ~900-1600 words for a substantive feature, shorter for a small change. Only link
250
+ (authenticity, anti-AI-tell, and punctuation rules these govern the PROSE; the em dash
251
+ in the `## Sources` template line is fixed template punctuation, and verbatim quoted
252
+ data such as commit subjects in `## Changelog` keeps its original punctuation),
253
+ but NOT any length/reach rules —
254
+ target ~900-1600 words of prose (code blocks don't count) for a substantive feature,
255
+ shorter for a small change. Only link
211
256
  commits where `public: true` in the scan; omit `## Changelog` if none are.
212
257
 
213
258
  ### Step 4: Self-check before publishing
@@ -217,10 +262,15 @@ Write each draft with the **Write tool** (never a bash heredoc) to a temp dir
217
262
  calls). Name it `<version>.md`. Then:
218
263
 
219
264
  1. **Lint:** `npx -y @natjswenson/devlog lint-post '<abs-draft-path>'` — fix every
220
- finding (missing sections, thin gotchas, too few distinct sources, untagged fences).
221
- 2. **Self-review against the how-to contract**, honestly, as a skeptical reader: walk
222
- points 1-7 above plus voice adherence. Revise the draft for any point that fails.
223
- 3. At most **two** revision passes; then proceed with the best version and carry any
265
+ finding (missing sections, thin gotchas, too few distinct sources, sources listed
266
+ but never cited inline, untagged fences).
267
+ 2. **Assemble-and-run check:** when the post's code is runnable without external
268
+ services, copy its code blocks in order into a scratch dir and execute them exactly
269
+ as a reader would. Anything undefined, out of order, or missing an entrypoint fails
270
+ the stranger test mechanically — fix the post, not just the scratch copy.
271
+ 3. **Self-review against the how-to contract**, honestly, as a skeptical reader: walk
272
+ points 1-8 above plus voice adherence. Revise the draft for any point that fails.
273
+ 4. At most **two** revision passes; then proceed with the best version and carry any
224
274
  residual weakness into the final summary (e.g. "v0.5.0: only 2 gotchas had commit
225
275
  evidence").
226
276
 
@@ -26,7 +26,9 @@ pip install httpx==0.27.0
26
26
 
27
27
  Start with the delay calculation, isolated so you can unit-test it. Full jitter means:
28
28
  sleep a uniform random amount between 0 and the exponential ceiling, which spreads
29
- retrying clients across the whole window instead of synchronizing them into waves.
29
+ retrying clients across the whole window instead of synchronizing them into waves
30
+ synchronized retries are exactly how a blip amplifies into an outage
31
+ ([Google SRE Book: Handling Overload](https://sre.google/sre-book/handling-overload/)).
30
32
 
31
33
  ```python
32
34
  import random
@@ -39,8 +41,9 @@ def backoff_delay(attempt: int, base: float = 0.5, cap: float = 30.0) -> float:
39
41
 
40
42
  ## Wrap it into a retry decorator
41
43
 
42
- The decorator retries only on retryable failures (connection errors and 5xx), never on
43
- 4xxa 404 will be a 404 no matter how many times you ask.
44
+ The decorator retries only on retryable failures (connection errors httpx's
45
+ [`TransportError` hierarchy](https://www.python-httpx.org/exceptions/) and 5xx), never
46
+ on 4xx: a 404 will be a 404 no matter how many times you ask.
44
47
 
45
48
  ```python
46
49
  import functools
@@ -104,8 +107,9 @@ randomized delays, then the final 503 returned.
104
107
  errors) explicitly.
105
108
  - **Equal jitter isn't enough under real outages.** I started with `ceiling/2 +
106
109
  uniform(0, ceiling/2)`. Symptom: load tests showed retry waves still clustering at the
107
- half-window mark. Escape: full jitter (`uniform(0, ceiling)`), which the AWS analysis
108
- below shows keeps total calls lowest across client counts.
110
+ half-window mark. Escape: full jitter (`uniform(0, ceiling)`), which the
111
+ [AWS backoff analysis](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)
112
+ shows keeps total calls lowest across client counts.
109
113
 
110
114
  ## Sources
111
115
 
@@ -15,8 +15,12 @@ system fits together.
15
15
 
16
16
  ## The validation layer
17
17
 
18
- The heart of it is the stage wrapper. It pulls the declared schema off the stage and
19
- routes failures to our dead-letter handler:
18
+ The heart of it is the stage wrapper, in the spirit of validating at boundaries
19
+ ([Martin Fowler on ContractTest](https://martinfowler.com/bliki/ContractTest.html)) and
20
+ declarative expectations ([Great Expectations documentation](https://docs.greatexpectations.io/docs/)).
21
+ It pulls the declared schema off the stage and routes failures to our dead-letter
22
+ handler, a pattern with deep roots in schema evolution
23
+ ([Designing Data-Intensive Applications](https://dataintensive.net/)):
20
24
 
21
25
  ```python
22
26
  def validated(stage):
package/lib/lint_post.mjs CHANGED
@@ -79,6 +79,12 @@ export function findUntaggedFences(body) {
79
79
  return untagged;
80
80
  }
81
81
 
82
+ // Ignore differences that don't change the destination: trailing slash and
83
+ // URL fragment.
84
+ export function normalizeUrl(url) {
85
+ return url.replace(/#.*$/, '').replace(/\/+$/, '');
86
+ }
87
+
82
88
  export function extractSourceUrls(sectionContent) {
83
89
  const urls = new Set();
84
90
  for (const m of sectionContent.matchAll(/\]\((https?:\/\/[^)\s]+)\)/g)) {
@@ -142,6 +148,19 @@ export function lintPost(content, { minSources = 3, filename = null } = {}) {
142
148
  if (urls.size < minSources) {
143
149
  add('sources-count', `Need at least ${minSources} distinct source URLs; found ${urls.size}.`);
144
150
  }
151
+ // The contract requires claims to carry their citation where they're made,
152
+ // not only in the bibliography: every Sources URL must also be cited
153
+ // inline somewhere else in the body.
154
+ const inline = new Set();
155
+ for (const s of sections) {
156
+ if (s.heading === 'Sources') continue;
157
+ for (const u of extractSourceUrls(s.content)) inline.add(normalizeUrl(u));
158
+ }
159
+ for (const u of urls) {
160
+ if (!inline.has(normalizeUrl(u))) {
161
+ add('sources-inline', `Source ${u} is listed in \`## Sources\` but never cited inline in the body — cite it where its claim is made, or drop it from Sources.`);
162
+ }
163
+ }
145
164
  }
146
165
 
147
166
  for (const line of findUntaggedFences(body)) {
@@ -7,12 +7,30 @@ import { join } from 'node:path';
7
7
  import { RE_PROJECT_KEY, RE_FINAL_RELEASE, atomicWriteJSON } from './core.mjs';
8
8
  import { parseFrontmatter } from './lint_post.mjs';
9
9
 
10
- // Newest-first by date; ties keep insertion order (Array.prototype.sort is
11
- // stable). Date order is normally also version order, but a backported tag
12
- // (v1.9.1 tagged after v2.0.0) can diverge sorting by date matches how the
13
- // feed renders.
10
+ // Newest-first by date; same-date ties break by version, highest first.
11
+ // Without the tiebreak, several releases cut on one day render oldest-on-top
12
+ // in the feed (stable sort keeps insertion order), burying the newest post
13
+ // under its predecessors. Date still wins overall because a backported tag
14
+ // (v1.9.1 tagged after v2.0.0) must sort by when it was released.
15
+ function versionNums(entry) {
16
+ const m = /^v(\d+(?:\.\d+)*)$/.exec(entry.version || '');
17
+ return m ? m[1].split('.').map(Number) : null;
18
+ }
19
+
20
+ function compareVersionsDesc(a, b) {
21
+ const va = versionNums(a);
22
+ const vb = versionNums(b);
23
+ if (!va || !vb) return 0; // legacy rows without a version keep their order
24
+ for (let i = 0; i < Math.max(va.length, vb.length); i++) {
25
+ const d = (vb[i] ?? 0) - (va[i] ?? 0);
26
+ if (d !== 0) return d;
27
+ }
28
+ return 0;
29
+ }
30
+
14
31
  function sortEntries(entries) {
15
- return entries.slice().sort((a, b) => String(b.date).localeCompare(String(a.date)));
32
+ return entries.slice().sort((a, b) =>
33
+ String(b.date).localeCompare(String(a.date)) || compareVersionsDesc(a, b));
16
34
  }
17
35
 
18
36
  export function publishEntry({ cloneDir, project, version, entryPath }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natjswenson/devlog",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Release dev log generator \u2014 Claude Code skill + preview app for publishing version-release dev logs, written in your voice, to your site",
5
5
  "license": "MIT",
6
6
  "author": "Nate Swenson",