@natjswenson/devlog 0.6.0 → 0.9.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.
@@ -23,6 +23,12 @@
23
23
  "remote": "yourusername/your-monorepo",
24
24
  "pathFilter": "skills/devlog",
25
25
  "tagPrefix": "devlog-v"
26
+ },
27
+ {
28
+ "key": "personal",
29
+ "label": "Personal",
30
+ "path": "/Users/yourusername/code/private-project",
31
+ "private": true
26
32
  }
27
33
  ]
28
34
  }
@@ -3,7 +3,7 @@ title: "Retries that don't stampede: exponential backoff with jitter in 40 lines
3
3
  date: 2026-07-10
4
4
  project: fixture
5
5
  version: v1.3.0
6
- tags: [reliability, python, distributed-systems]
6
+ tags: [reliability, python, distributed-systems, backoff, jitter]
7
7
  summary: "This release moved our flaky HTTP calls behind a retry wrapper. Here's how to build one with full jitter, and the two traps that bit me."
8
8
  ---
9
9
 
@@ -3,7 +3,7 @@ title: "How our pipeline learned to validate itself end-to-end"
3
3
  date: 2026-07-10
4
4
  project: fixture
5
5
  version: v2.1.0
6
- tags: [testing, data-pipelines, python]
6
+ tags: [testing, data-pipelines, python, etl, validation]
7
7
  summary: "v2.1.0 added self-validating pipeline stages. A look at how the validation layer came together."
8
8
  ---
9
9
 
Binary file
@@ -0,0 +1,234 @@
1
+ # Cover icon catalog
2
+
3
+ Twenty small, secondary/accent icons — **never the hero illustration itself**. Each is a
4
+ real inline SVG, 24×24 viewBox, stroke-only (`stroke="currentColor" fill="none"`), so it
5
+ recolors for free via CSS `color: #ededed` or `color: #fff503` (the accent yellow) —
6
+ never `fill`. Wrap every usage in a container carrying `data-catalog-icon="<name>"`
7
+ (the exact `<name>` from the table below) — `render-cover`'s geometry guard reads this
8
+ attribute to confirm no catalog icon has drifted into the hero zone.
9
+
10
+ **These are for the kicker-area accent glyph only** (see `style-guide.md`'s hero-zone
11
+ grid contract) — never for the hero illustration's own mechanism/nodes, which are always
12
+ freehand SVG the agent draws itself. Look up a concept below instead of re-deriving an
13
+ icon from scratch; if a post's concept doesn't map cleanly to any of these 20, that's a
14
+ signal the post doesn't need an accent icon at all (a terminal-glyph accent or no accent
15
+ is always a valid choice — see `style-guide.md`).
16
+
17
+ ## Topic → icon cheat sheet
18
+
19
+ | Topic / keywords in title or summary | Icon |
20
+ |---|---|
21
+ | agent, LLM, Claude, subagent, prompt | `agents` |
22
+ | test, testing, assert, spec, coverage | `testing` |
23
+ | CI, CD, pipeline, workflow, build | `ci-cd` |
24
+ | git, commit, branch, merge, tag | `git` |
25
+ | a11y, accessibility, aria, screen reader | `accessibility` |
26
+ | debug, bug, fix, root cause, trace | `debugging` |
27
+ | CLI, command, terminal, flag, argv | `cli` |
28
+ | config, settings, options, flags | `config` |
29
+ | deploy, release, ship, publish, rollout | `deploy` |
30
+ | database, manifest, schema, storage | `database` |
31
+ | API, endpoint, request, response | `api` |
32
+ | search, filter, query, lookup | `search` |
33
+ | auth, login, token, credential, permission | `auth` |
34
+ | monitor, metric, telemetry, dashboard | `monitoring` |
35
+ | cover, image, render, screenshot, thumbnail | `cover-image` |
36
+ | performance, speed, latency, throughput | `performance` |
37
+ | parse, parser, tokenize, frontmatter | `parsing` |
38
+ | cache, staging, memoize, invalidate | `caching` |
39
+ | UI, layout, component, page, nav | `ui` |
40
+ | network, remote, fetch, clone, push/pull | `networking` |
41
+
42
+ ## Icons
43
+
44
+ ### `agents`
45
+ ```svg
46
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
47
+ <rect x="5" y="8" width="14" height="11" rx="2"/>
48
+ <line x1="12" y1="8" x2="12" y2="4"/>
49
+ <circle cx="12" cy="3" r="1"/>
50
+ <circle cx="9" cy="13" r="1.2"/>
51
+ <circle cx="15" cy="13" r="1.2"/>
52
+ <line x1="9" y1="17" x2="15" y2="17"/>
53
+ </svg>
54
+ ```
55
+
56
+ ### `testing`
57
+ ```svg
58
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
59
+ <rect x="4" y="4" width="16" height="16" rx="2"/>
60
+ <polyline points="8,12.5 11,15.5 16,9"/>
61
+ </svg>
62
+ ```
63
+
64
+ ### `ci-cd`
65
+ ```svg
66
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
67
+ <circle cx="4.5" cy="12" r="2"/>
68
+ <circle cx="12" cy="12" r="2"/>
69
+ <circle cx="19.5" cy="12" r="2"/>
70
+ <line x1="6.5" y1="12" x2="10" y2="12"/>
71
+ <line x1="14" y1="12" x2="17.5" y2="12"/>
72
+ <polyline points="15.5,10 17.5,12 15.5,14"/>
73
+ </svg>
74
+ ```
75
+
76
+ ### `git`
77
+ ```svg
78
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
79
+ <circle cx="6" cy="6" r="2"/>
80
+ <circle cx="6" cy="18" r="2"/>
81
+ <circle cx="18" cy="10" r="2"/>
82
+ <line x1="6" y1="8" x2="6" y2="16"/>
83
+ <path d="M6 8 C6 10, 8 10, 12 10 S18 10, 18 12"/>
84
+ </svg>
85
+ ```
86
+
87
+ ### `accessibility`
88
+ ```svg
89
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
90
+ <circle cx="12" cy="12" r="9"/>
91
+ <circle cx="12" cy="7.5" r="1.4"/>
92
+ <line x1="7" y1="11" x2="17" y2="11"/>
93
+ <line x1="12" y1="11" x2="12" y2="15"/>
94
+ <line x1="12" y1="15" x2="9" y2="18.5"/>
95
+ <line x1="12" y1="15" x2="15" y2="18.5"/>
96
+ </svg>
97
+ ```
98
+
99
+ ### `debugging`
100
+ ```svg
101
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
102
+ <rect x="8" y="8" width="8" height="10" rx="4"/>
103
+ <line x1="12" y1="4" x2="12" y2="8"/>
104
+ <line x1="6" y1="10" x2="8" y2="11"/>
105
+ <line x1="6" y1="14" x2="8" y2="14"/>
106
+ <line x1="6" y1="18" x2="8" y2="17"/>
107
+ <line x1="18" y1="10" x2="16" y2="11"/>
108
+ <line x1="18" y1="14" x2="16" y2="14"/>
109
+ <line x1="18" y1="18" x2="16" y2="17"/>
110
+ </svg>
111
+ ```
112
+
113
+ ### `cli`
114
+ ```svg
115
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
116
+ <rect x="3" y="4" width="18" height="16" rx="2"/>
117
+ <polyline points="7,10 10,12.5 7,15"/>
118
+ <line x1="12" y1="15" x2="16" y2="15"/>
119
+ </svg>
120
+ ```
121
+
122
+ ### `config`
123
+ ```svg
124
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
125
+ <circle cx="12" cy="12" r="3"/>
126
+ <path d="M12 3v2.2M12 18.8V21M3 12h2.2M18.8 12H21M5.6 5.6l1.5 1.5M16.9 16.9l1.5 1.5M18.4 5.6l-1.5 1.5M7.1 16.9l-1.5 1.5"/>
127
+ </svg>
128
+ ```
129
+
130
+ ### `deploy`
131
+ ```svg
132
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
133
+ <line x1="12" y1="19" x2="12" y2="6"/>
134
+ <polyline points="6,12 12,6 18,12"/>
135
+ <line x1="5" y1="20" x2="19" y2="20"/>
136
+ </svg>
137
+ ```
138
+
139
+ ### `database`
140
+ ```svg
141
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
142
+ <ellipse cx="12" cy="6" rx="7" ry="2.5"/>
143
+ <path d="M5 6v12c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V6"/>
144
+ <path d="M5 12c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>
145
+ </svg>
146
+ ```
147
+
148
+ ### `api`
149
+ ```svg
150
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
151
+ <polyline points="9,5 3,12 9,19"/>
152
+ <polyline points="15,5 21,12 15,19"/>
153
+ </svg>
154
+ ```
155
+
156
+ ### `search`
157
+ ```svg
158
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
159
+ <circle cx="10.5" cy="10.5" r="6"/>
160
+ <line x1="15" y1="15" x2="20" y2="20"/>
161
+ </svg>
162
+ ```
163
+
164
+ ### `auth`
165
+ ```svg
166
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
167
+ <rect x="5" y="11" width="14" height="9" rx="2"/>
168
+ <path d="M8 11V7a4 4 0 0 1 8 0v4"/>
169
+ <circle cx="12" cy="15" r="1.3"/>
170
+ </svg>
171
+ ```
172
+
173
+ ### `monitoring`
174
+ ```svg
175
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
176
+ <polyline points="3,14 8,14 10,8 14,18 16,14 21,14"/>
177
+ </svg>
178
+ ```
179
+
180
+ ### `cover-image`
181
+ ```svg
182
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
183
+ <rect x="3" y="5" width="18" height="14" rx="2"/>
184
+ <circle cx="8.5" cy="10" r="1.5"/>
185
+ <polyline points="4,17 9,12 13,16 16,13 20,17"/>
186
+ </svg>
187
+ ```
188
+
189
+ ### `performance`
190
+ ```svg
191
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
192
+ <path d="M4 16a8 8 0 0 1 16 0"/>
193
+ <line x1="12" y1="16" x2="16" y2="10.5"/>
194
+ <circle cx="12" cy="16" r="1"/>
195
+ </svg>
196
+ ```
197
+
198
+ ### `parsing`
199
+ ```svg
200
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
201
+ <path d="M9 4c-2 0-3 1-3 3v3c0 1-1 2-2 2 1 0 2 1 2 2v3c0 2 1 3 3 3"/>
202
+ <path d="M15 4c2 0 3 1 3 3v3c0 1 1 2 2 2-1 0-2 1-2 2v3c0 2-1 3-3 3"/>
203
+ </svg>
204
+ ```
205
+
206
+ ### `caching`
207
+ ```svg
208
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
209
+ <rect x="5" y="4" width="14" height="4.5" rx="1"/>
210
+ <rect x="5" y="9.75" width="14" height="4.5" rx="1"/>
211
+ <rect x="5" y="15.5" width="14" height="4.5" rx="1"/>
212
+ </svg>
213
+ ```
214
+
215
+ ### `ui`
216
+ ```svg
217
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
218
+ <rect x="3" y="4" width="18" height="16" rx="2"/>
219
+ <line x1="3" y1="8.5" x2="21" y2="8.5"/>
220
+ <line x1="8" y1="4" x2="8" y2="8.5"/>
221
+ </svg>
222
+ ```
223
+
224
+ ### `networking`
225
+ ```svg
226
+ <svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
227
+ <circle cx="12" cy="5" r="2"/>
228
+ <circle cx="5" cy="18" r="2"/>
229
+ <circle cx="19" cy="18" r="2"/>
230
+ <line x1="12" y1="7" x2="5" y2="16"/>
231
+ <line x1="12" y1="7" x2="19" y2="16"/>
232
+ <line x1="7" y1="18" x2="17" y2="18"/>
233
+ </svg>
234
+ ```
@@ -0,0 +1,138 @@
1
+ # Cover image style guide
2
+
3
+ This is the fallback style guide devlog uses when composing a post's cover image. It is
4
+ generic. Replace it with your own — the more specific the visual direction, the more
5
+ consistent your covers will look across ~50+ posts.
6
+
7
+ devlog reads this file, plus (when available) the `n` most recently published covers as
8
+ reference images, before composing a new cover. **Actually look at the reference images
9
+ before composing** — the style guide alone under-specifies layout/spacing; the references
10
+ are how a from-scratch visual language stays consistent from post to post.
11
+
12
+ ## What you're composing
13
+
14
+ A single self-contained HTML document (inline `<style>`, inline SVG for any artwork) that
15
+ renders, at exactly 1600×900px, as a cover image for one dev log entry. Compose from these
16
+ fields only — never open the post's raw markdown file, never reference any section other
17
+ than `## Shipped` (no `## Changelog`, no gotchas, no sources):
18
+ - `title`
19
+ - `tags`
20
+ - `summary`
21
+ - the `## Shipped` section's text
22
+
23
+ ## The point of this cover: one custom illustration, not a repeated headline
24
+
25
+ **A cover that just re-renders the title in large text is a failure, no matter how clean
26
+ the typography is.** The title is already the `<h1>` on the post page one scroll down —
27
+ the cover's job is to give the reader something to *look at*, a specific visual idea that
28
+ came from *this* post's content and would look wrong on any other post.
29
+
30
+ Before writing any HTML, do this thinking step explicitly:
31
+ 1. Read the title, summary, and `## Shipped` text.
32
+ 2. Identify the one concrete technical concept or moment the post is actually about —
33
+ not the project name, not "a bug fix," but the specific mechanism: a lock rejecting a
34
+ key that doesn't fit, a git tag being distrusted like any other user input, one broken
35
+ link in a chain, a filter separating signal from noise, a clock catching a stale
36
+ timestamp, two paths diverging and one being cut off.
37
+ 3. Design ONE illustration — built from inline SVG shapes (lines, arcs, polygons,
38
+ simple geometric forms) — that depicts that concept. Not a photo, not a stock icon,
39
+ not a screenshot: a small original line-art scene, in the spirit of an editorial
40
+ illustration or a technical diagram, using only the palette below.
41
+ 4. That illustration is the dominant visual element of the cover — roughly half the
42
+ canvas, not a thumbnail in the corner. Title, kicker, and summary text support it;
43
+ they do not replace it.
44
+
45
+ **Two different posts should never produce visually similar covers.** If your instinct is
46
+ to reach for a generic circle/square/checkmark because you're not sure what else to draw,
47
+ that's a sign to go back to step 2 and find the more specific concept — a post about
48
+ retrying a flaky network call and a post about deduplicating bank transactions should not
49
+ end up with the same shape family.
50
+
51
+ ## Hero-zone grid contract
52
+
53
+ The hero illustration renders inside a fixed bounding box: `x:150 y:425 width:1300 height:400` on the 1600×900 canvas (below the kicker/title area). Draw a container
54
+ element with `id="hero-zone"` at exactly this position and size — `render-cover`
55
+ mechanically checks the rendered `#hero-zone` rect against these numbers (within a 2px
56
+ tolerance for subpixel rounding) and refuses to render if it doesn't match, or if
57
+ `#hero-zone` is missing or duplicated. This is a hard requirement, not a suggestion —
58
+ every post's hero renders inside the identical box so covers stay comparable.
59
+
60
+ Every key point of the hero shape you draw *inside* `#hero-zone` should snap to a 25px coordinate grid (this is prose guidance, not mechanically checked — the guard verifies the outer box only) — pick coordinates as multiples of 25px from `#hero-zone`'s own top-left corner. This fixes near-misses and uneven spacing; it does not mean the shapes themselves must be simple, only that their key points land on a consistent rhythm.
61
+
62
+ **Two named composition slots** — pick one per post:
63
+ - **Single centered hero** — one freehand mechanism, nothing else, inside the hero zone.
64
+ - **Two-node before/after** — a left node, a right node, and a connecting line, all
65
+ three freehand shapes (never catalog icons) — for a post about a transformation or a
66
+ fix.
67
+
68
+ These are placement/proportion guidance, not literal templates — the actual shapes
69
+ inside each slot are still freehand per post.
70
+
71
+ **Catalog icons are never placed inside `#hero-zone`, in either slot.** The mechanism
72
+ and both two-node shapes are always freehand SVG you draw yourself. A catalog icon
73
+ (`image-style/icons.md`) may only appear as a small accent glyph in the kicker/title
74
+ area, entirely outside the hero zone.
75
+
76
+ **Optional kicker-area accent icon.** Independent of which of the two slots you picked, you may add one small accent glyph near the kicker/title area — either a catalog icon (`image-style/icons.md`) or a terminal/code aesthetic glyph (`$`, `>`, `//`, brackets). Never combine the catalog-icon accent and the terminal-glyph accent in the same cover.
77
+
78
+ If you use a catalog-icon accent, it must be positioned with its bottom edge no lower than y:400 — a 25px buffer above the hero zone's y:425 top edge — so it can never clip into the hero zone and trip the geometry guard.
79
+
80
+ For the two-node slot specifically: the accent icon's presence must not be read as belonging to either node; it sits in the kicker/title area purely as a page-level decoration, unrelated to the two-node layout below it.
81
+
82
+ ## Technical requirements (non-negotiable)
83
+
84
+ - Start the document with a literal `<!DOCTYPE html>` declaration, always.
85
+ - `html, body { margin: 0; width: 1600px; height: 900px; }` — the render is a
86
+ viewport-clipped screenshot at exactly this size; content that overflows this box is
87
+ simply never captured, so keep everything inside it.
88
+ - Reference the bundled font only by its fixed name, with a fallback:
89
+ `font-family: 'DevlogCoverFont', sans-serif;` — never embed font bytes yourself, never
90
+ reference any other font file. The renderer injects the real font after your markup is
91
+ parsed.
92
+ - No external resources of any kind — no `<link>`, no `@import`, no remote `<img src>`,
93
+ no web fonts, no raster images. All artwork is inline SVG built from basic shapes
94
+ (`<path>`, `<circle>`, `<rect>`, `<line>`, `<polygon>`, `<polyline>`) — everything must
95
+ be inline HTML/CSS/SVG, hand-composed, not fetched or embedded from anywhere.
96
+
97
+ ## Visual direction
98
+
99
+ The site (natejswenson.com) is a minimalist, monospace, terminal-styled dev log. Covers
100
+ should feel like they belong to the same publication as the site itself — technical
101
+ editorial illustrations, not marketing graphics and not a repeated template:
102
+
103
+ - **Palette:** background `#0a0a0b` (near-black), foreground/line-art color `#ededed`,
104
+ secondary/dim `#8a8a8a`, one accent color `#fff503` (yellow) for the single most
105
+ important element of the illustration — the thing being emphasized, not a decoration.
106
+ Prefer 2-3 colors on a page (black, white, one accent), not a rainbow. Prefer flat,
107
+ limited color and solid/line fills over large smooth gradients — the render is
108
+ compressed with lossy PNG palette quantization afterward, and gradients band visibly
109
+ under that compression while flat fills don't.
110
+ - **Typography:** `'DevlogCoverFont'` (a monospace face) for any on-image text — kicker,
111
+ title, date. Keep the title modest in size (it is not the main event); a short kicker
112
+ (project + date) is enough context. Terminal/code aesthetic glyphs (`$`, `>`, `//`,
113
+ brackets) are fair game as small accents, not as the illustration itself.
114
+ - **Composition:** the illustration occupies the dominant visual weight of the canvas —
115
+ centered or offset to one side, large enough to read at a glance, with the
116
+ title/kicker in the remaining negative space (not overlapping the artwork). Plenty of
117
+ breathing room around the illustration; don't crowd it with text or decoration.
118
+ - **Restraint in execution, not in ambition:** the illustration should be a real, specific
119
+ scene (multiple shapes composed together to depict one concept), not a single
120
+ primitive. But avoid clutter — every shape in the illustration should serve the one
121
+ concept, not decorate around it.
122
+
123
+ ## Never do
124
+
125
+ - Don't make the title the largest, most prominent element on the cover — the
126
+ illustration is.
127
+ - Don't restate the version number as the headline ("v0.6.0" as the big text) — use the
128
+ post's actual title, and keep it secondary to the artwork.
129
+ - Don't reuse the same illustration, shape family, or visual metaphor across different
130
+ posts — go back to the post's actual content and find what's specific to it.
131
+ - Don't fall back to a generic circle/square/checkmark/arrow when stuck — that's the
132
+ exact failure mode this guide exists to prevent. Spend the extra step finding the
133
+ concrete mechanism the post describes.
134
+ - Don't use a gradient as a full-bleed background.
135
+ - Don't embed a photograph, stock image, or anything requiring an external fetch — the
136
+ illustration is drawn from inline SVG primitives, not sourced from anywhere.
137
+ - Don't reference any font other than `'DevlogCoverFont'` (with its `sans-serif`
138
+ fallback).
@@ -3,11 +3,14 @@
3
3
  // reading/writing ~/.claude/skills/devlog/config.json atomically.
4
4
  import { validateConfig, expandHome } from './core.mjs';
5
5
 
6
- export function addProject(config, { key, path, remote, label, tagPrefix, pathFilter }) {
6
+ export function addProject(config, { key, path, remote, label, tagPrefix, pathFilter, private: isPrivate }) {
7
7
  if (config.projects.some((p) => p.key === key)) {
8
8
  throw new Error(`Project key "${key}" is already registered.`);
9
9
  }
10
- const project = { key, path: expandHome(path), remote };
10
+ const project = { key, path: expandHome(path) };
11
+ // remote is required unless the project is private (see core.mjs validateConfig).
12
+ if (remote) project.remote = remote;
13
+ if (isPrivate) project.private = true;
11
14
  if (label) project.label = label;
12
15
  // Only persist tagPrefix when it differs from the default `v` (keeps configs clean).
13
16
  if (tagPrefix && tagPrefix !== 'v') project.tagPrefix = tagPrefix;
package/lib/core.mjs CHANGED
@@ -154,7 +154,18 @@ export function validateConfig(config) {
154
154
  if (typeof p.path !== 'string' || SHELL_QUOTE_BREAK.test(p.path)) {
155
155
  throw new Error(`project.path invalid (must contain no shell metacharacters): ${JSON.stringify(p.path)}`);
156
156
  }
157
- if (!RE_OWNER_REPO.test(p.remote)) {
157
+ if ('private' in p && typeof p.private !== 'boolean') {
158
+ throw new Error(`project.private must be a boolean if present: ${JSON.stringify(p.private)}`);
159
+ }
160
+ // A private project has no safe public commit surface, so `remote` is
161
+ // optional (it's never used to build a commit link — see scan.mjs's
162
+ // isPublic). If supplied anyway, for the maintainer's own reference, it
163
+ // must still be well-formed.
164
+ if (p.private) {
165
+ if ('remote' in p && p.remote !== undefined && !RE_OWNER_REPO.test(p.remote)) {
166
+ throw new Error(`project.remote must match <owner>/<repo>: ${JSON.stringify(p.remote)}`);
167
+ }
168
+ } else if (!RE_OWNER_REPO.test(p.remote)) {
158
169
  throw new Error(`project.remote must match <owner>/<repo>: ${JSON.stringify(p.remote)}`);
159
170
  }
160
171
  if ('pathFilter' in p) {
@@ -0,0 +1,137 @@
1
+ // Style guide + reference-image lookup for cover-image composition. Deterministic,
2
+ // no LLM/agent involvement — the agent calls `devlog cover-context` (bin/devlog.js),
3
+ // which wraps these two functions.
4
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { CONFIG_DIR } from './core.mjs';
7
+
8
+ const IMAGE_STYLE_DIR = join(CONFIG_DIR, 'image-style');
9
+ const STYLE_GUIDE_PATH = join(IMAGE_STYLE_DIR, 'style-guide.md');
10
+ const ICON_CATALOG_PATH = join(IMAGE_STYLE_DIR, 'icons.md');
11
+
12
+ function slugFromFile(file) {
13
+ return String(file || '').replace(/\.md$/, '');
14
+ }
15
+
16
+ // Pure, given explicit paths — exported separately so tests can exercise the
17
+ // missing-icons.md degradation deterministically against a temp directory, without
18
+ // touching this machine's real installed state at CONFIG_DIR.
19
+ export function resolveStyleGuideAndCatalog(styleGuidePath, iconCatalogPath) {
20
+ if (!existsSync(styleGuidePath)) {
21
+ throw new Error(`Cover style guide not found at ${styleGuidePath} — run \`devlog init\` to install it.`);
22
+ }
23
+ const text = readFileSync(styleGuidePath, 'utf8');
24
+ const iconCatalog = existsSync(iconCatalogPath) ? readFileSync(iconCatalogPath, 'utf8') : null;
25
+ return { text, iconCatalog };
26
+ }
27
+
28
+ // Reads the installed style guide (no graceful degradation — Claude has nothing to
29
+ // compose from without it; callers (devlog cover-context) catch the throw and surface it
30
+ // as a distinct error, never blocking the rest of publish) plus the installed icon catalog
31
+ // (graceful degradation here: iconCatalog: null when icons.md isn't installed, mirroring
32
+ // the missing-style-guide handling pattern, just one level down — composition proceeds
33
+ // without a catalog rather than blocking).
34
+ export function loadStyleGuide() {
35
+ return resolveStyleGuideAndCatalog(STYLE_GUIDE_PATH, ICON_CATALOG_PATH);
36
+ }
37
+
38
+ // Read one project's manifest.json out of an already-established clone.
39
+ // Two distinct missing-manifest cases (mirrors fetchExistingEntries()'s empty/failed split
40
+ // in lib/scan.mjs): a project directory that doesn't exist at all (never published a
41
+ // release yet) is zero entries, not an error. A project directory that DOES exist but
42
+ // whose manifest.json is missing or fails to parse is a genuine anomaly — surfaced via
43
+ // status: 'failed' so the caller can throw a clear, named error rather than silently
44
+ // dropping that project's entries from a merged result.
45
+ function readProjectManifest(cloneDir, projectKey) {
46
+ const projectDir = join(cloneDir, projectKey);
47
+ if (!existsSync(projectDir)) {
48
+ return { entries: [], status: 'empty' };
49
+ }
50
+ const manifestPath = join(projectDir, 'manifest.json');
51
+ if (!existsSync(manifestPath)) {
52
+ return { entries: [], status: 'failed', reason: `manifest.json missing for project "${projectKey}" (expected at ${manifestPath})` };
53
+ }
54
+ let manifest;
55
+ try {
56
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
57
+ } catch (e) {
58
+ return { entries: [], status: 'failed', reason: `manifest.json for project "${projectKey}" failed to parse: ${e.message}` };
59
+ }
60
+ if (!manifest || !Array.isArray(manifest.entries)) {
61
+ return { entries: [], status: 'failed', reason: `manifest.json for project "${projectKey}" is malformed (expected { "entries": [...] })` };
62
+ }
63
+ return { entries: manifest.entries, status: 'ok' };
64
+ }
65
+
66
+ // Cross-project manifest enumeration/merge — the one mechanism shared by
67
+ // getRecentCovers() (below) and `devlog backfill-covers list` (bin/devlog.js). There is no
68
+ // merged/aggregated manifest file anywhere in daily-dev-log (confirmed: exactly one
69
+ // manifest.json per project directory) — every project's own manifest.json is read
70
+ // individually and tagged with `project` as it's read, mirroring getAllEntries() in
71
+ // natejswenson.io/src/lib/devlog.js (the site's own equivalent per-project-manifest merge;
72
+ // its own empty-vs-corrupt handling is indirect/local-overlay-based, not the same
73
+ // mechanism as this direct directory check, though it reaches the same outcome for the
74
+ // directory-absent case).
75
+ export function mergeManifestEntries(cloneDir, config) {
76
+ const merged = [];
77
+ for (const p of (config.projects || [])) {
78
+ const { entries, status, reason } = readProjectManifest(cloneDir, p.key);
79
+ if (status === 'failed') throw new Error(reason);
80
+ for (const e of entries) merged.push({ ...e, project: p.key });
81
+ }
82
+ return merged;
83
+ }
84
+
85
+ // Staged-but-uncommitted covers from earlier in the same backfill session, freshest first
86
+ // by file mtime. Resolved under stagingDir's per-project subdirectory
87
+ // (<stagingDir>/<project>/<slug>.png) — never a flat <stagingDir>/<slug>.png, since slugs
88
+ // are not globally unique across projects.
89
+ function listStagedCovers(stagingDir) {
90
+ if (!stagingDir || !existsSync(stagingDir)) return [];
91
+ const out = [];
92
+ for (const d of readdirSync(stagingDir, { withFileTypes: true })) {
93
+ if (!d.isDirectory()) continue; // skips the top-level index.html contact sheet
94
+ const projectDir = join(stagingDir, d.name);
95
+ for (const f of readdirSync(projectDir)) {
96
+ if (!f.endsWith('.png')) continue;
97
+ const full = join(projectDir, f);
98
+ out.push({ project: d.name, slug: f.replace(/\.png$/, ''), path: full, mtimeMs: statSync(full).mtimeMs });
99
+ }
100
+ }
101
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
102
+ return out.map(({ project, slug, path }) => ({ project, slug, path }));
103
+ }
104
+
105
+ // Pure given its inputs. Returns the N most recently published covers across every
106
+ // configured project (newest by manifest `date`), as local paths inside the already-
107
+ // established clone. When stagingDir is given and fewer than n published covers exist,
108
+ // tops up the result with covers already staged this session (freshest-first) — what lets
109
+ // a from-scratch backfill session bootstrap its own visual consistency instead of every
110
+ // candidate composing from the style guide alone. Returns [] when neither source has
111
+ // anything yet (the true first-cover case) — never throws for that, never pads.
112
+ export function getRecentCovers({ cloneDir, config, stagingDir = null, n = 3 }) {
113
+ const merged = mergeManifestEntries(cloneDir, config);
114
+ const covered = merged
115
+ .filter((e) => e && e.cover && e.file)
116
+ .map((e) => ({ project: e.project, slug: slugFromFile(e.file), date: e.date }))
117
+ .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
118
+
119
+ const result = covered.slice(0, n).map(({ project, slug }) => ({
120
+ project,
121
+ slug,
122
+ path: join(cloneDir, project, `${slug}.png`),
123
+ }));
124
+
125
+ if (result.length < n) {
126
+ const seen = new Set(result.map((r) => `${r.project}/${r.slug}`));
127
+ for (const staged of listStagedCovers(stagingDir)) {
128
+ if (result.length >= n) break;
129
+ const key = `${staged.project}/${staged.slug}`;
130
+ if (seen.has(key)) continue;
131
+ seen.add(key);
132
+ result.push(staged);
133
+ }
134
+ }
135
+
136
+ return result;
137
+ }
package/lib/lint_post.mjs CHANGED
@@ -10,6 +10,23 @@ export const REQUIRED_SECTIONS = ['Shipped', 'Gotchas', 'Sources'];
10
10
 
11
11
  const RE_DATE = /^\d{4}-\d{2}-\d{2}$/;
12
12
 
13
+ export const TAG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
14
+
15
+ // First-occurrence wins: keeps the first casing seen, drops later
16
+ // case-insensitive duplicates. A same-case repeat is the reachable case in
17
+ // practice — TAG_PATTERN already forbids the case-differing variant outright.
18
+ export function dedupeCaseInsensitive(tags) {
19
+ const seen = new Set();
20
+ const out = [];
21
+ for (const t of tags) {
22
+ const key = t.toLowerCase();
23
+ if (seen.has(key)) continue;
24
+ seen.add(key);
25
+ out.push(t);
26
+ }
27
+ return out;
28
+ }
29
+
13
30
  // Minimal frontmatter parser: `--- ... ---` fence, `key: value` lines, flow
14
31
  // arrays for tags. Prototype-free target object; unknown keys are kept (the
15
32
  // contract does not forbid extras) but only allowlisted keys are checked.
@@ -121,8 +138,18 @@ export function lintPost(content, { minSources = 3, filename = null } = {}) {
121
138
  add('title-style', 'Title must be essay-style, not a "release vX.Y.Z" label.');
122
139
  }
123
140
  }
124
- if (Array.isArray(data.tags) && (data.tags.length < 2 || data.tags.length > 5)) {
125
- add('tags-count', `Expected 2-5 topic tags, got ${data.tags.length}.`);
141
+ if (Array.isArray(data.tags) && (data.tags.length < 5 || data.tags.length > 10)) {
142
+ add('tags-count', `Expected 5-10 topic tags, got ${data.tags.length}.`);
143
+ }
144
+ if (Array.isArray(data.tags)) {
145
+ for (const t of data.tags) {
146
+ if (typeof t === 'string' && !TAG_PATTERN.test(t)) {
147
+ add('tags-character-pattern', `Tag "${t}" must be lowercase alphanumeric/hyphens only (^[a-z0-9][a-z0-9-]*$).`);
148
+ }
149
+ }
150
+ if (dedupeCaseInsensitive(data.tags).length !== data.tags.length) {
151
+ add('tags-duplicate', 'Tags contain a case-insensitive duplicate.');
152
+ }
126
153
  }
127
154
  if (filename && typeof data.version === 'string' && data.version) {
128
155
  const expected = `${data.version}.md`;