@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.
@@ -2,11 +2,33 @@
2
2
  // manifest. This is the code-enforced immutability guard: a cut release's
3
3
  // entry is never overwritten, and manifest mutation is no longer done by
4
4
  // hand-editing JSON in the agent loop.
5
- import { existsSync, mkdirSync, readFileSync, copyFileSync } from 'node:fs';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
6
6
  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
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
11
+
12
+ function isValidPng(path) {
13
+ let fd;
14
+ try {
15
+ fd = openSync(path, 'r');
16
+ const buf = Buffer.alloc(8);
17
+ const n = readSync(fd, buf, 0, 8, 0);
18
+ return n === 8 && buf.equals(PNG_MAGIC);
19
+ } catch {
20
+ return false;
21
+ } finally {
22
+ if (fd !== undefined) closeSync(fd);
23
+ }
24
+ }
25
+
26
+ function assertSafeSlug(slug) {
27
+ if (typeof slug !== 'string' || slug === '' || slug.includes('/') || slug.includes('..') || /[\x00-\x1f]/.test(slug)) {
28
+ throw new Error(`Invalid slug: ${JSON.stringify(slug)}`);
29
+ }
30
+ }
31
+
10
32
  // Newest-first by date; same-date ties break by version, highest first.
11
33
  // Without the tiebreak, several releases cut on one day render oldest-on-top
12
34
  // in the feed (stable sort keeps insertion order), burying the newest post
@@ -33,7 +55,7 @@ function sortEntries(entries) {
33
55
  String(b.date).localeCompare(String(a.date)) || compareVersionsDesc(a, b));
34
56
  }
35
57
 
36
- export function publishEntry({ cloneDir, project, version, entryPath }) {
58
+ export function publishEntry({ cloneDir, project, version, entryPath, coverImageBuffer }) {
37
59
  if (!RE_PROJECT_KEY.test(project) || project.includes('..')) {
38
60
  throw new Error(`Invalid project key: ${JSON.stringify(project)}`);
39
61
  }
@@ -58,6 +80,17 @@ export function publishEntry({ cloneDir, project, version, entryPath }) {
58
80
  mkdirSync(projectDir, { recursive: true });
59
81
  copyFileSync(entryPath, destPath);
60
82
 
83
+ // Cover write happens between the .md write and the manifest mutation, matching the
84
+ // existing .md-then-manifest crash-recovery convention: a process death after this write
85
+ // but before the manifest mutation leaves an orphaned <version>.png with no matching
86
+ // `cover` field — harmless inert clutter (the manifest is the sole source of truth for
87
+ // "does this post have a cover"), not a correctness bug, and needs no cleanup logic.
88
+ let coverFile = null;
89
+ if (coverImageBuffer) {
90
+ coverFile = `${version}.png`;
91
+ writeFileSync(join(projectDir, coverFile), coverImageBuffer);
92
+ }
93
+
61
94
  const manifestPath = join(projectDir, 'manifest.json');
62
95
  let manifest = { entries: [] };
63
96
  if (existsSync(manifestPath)) {
@@ -79,11 +112,90 @@ export function publishEntry({ cloneDir, project, version, entryPath }) {
79
112
  title: String(data.title),
80
113
  summary: String(data.summary),
81
114
  version,
115
+ tags: Array.isArray(data.tags) ? data.tags : [],
116
+ ...(coverFile ? { cover: { file: coverFile, bytes: coverImageBuffer.length } } : {}),
82
117
  });
83
118
  manifest.entries = sortEntries(manifest.entries);
84
119
  atomicWriteJSON(manifestPath, manifest);
85
120
  manifestUpdated = true;
86
121
  }
87
122
 
88
- return { written: destPath, manifestUpdated };
123
+ return { written: destPath, manifestUpdated, coverWritten: !!coverFile };
124
+ }
125
+
126
+ // Backfill path only: add a cover to an entry that was already published without one.
127
+ // Never writes/reads <slug>.md, never pushes a new manifest row — its only mutation is the
128
+ // `cover` field of an already-existing entry, keyed by that entry's version/file stem.
129
+ //
130
+ // Three-way branch, in order:
131
+ // 1. force: true -> ALWAYS overwrite the clone-destination PNG unconditionally,
132
+ // no magic-byte check, no adoption logic. This is what
133
+ // "force" means: --force is used precisely when a row
134
+ // already has `cover`, so an ungated adoption check would
135
+ // otherwise silently skip the write exactly when the caller
136
+ // most clearly intends to overwrite.
137
+ // 2. !force, row has cover -> throw/refuse before any write.
138
+ // 3. !force, row lacks cover -> the only branch where magic-byte adopt-or-discard logic
139
+ // applies. Kept as cheap, harmless insurance for a resume
140
+ // scenario that is NOT reachable via commit-covers's actual
141
+ // call pattern (commit-covers always establishes a fresh
142
+ // clone and performs exactly one commit+push at the very
143
+ // end, so a crash mid-run never leaves this orphan
144
+ // discoverable by a later invocation) — not a claim that
145
+ // this state occurs in practice.
146
+ export function addCoverToExistingEntry({ cloneDir, project, slug, coverImageBuffer, force = false }) {
147
+ if (!RE_PROJECT_KEY.test(project) || project.includes('..')) {
148
+ throw new Error(`Invalid project key: ${JSON.stringify(project)}`);
149
+ }
150
+ assertSafeSlug(slug);
151
+ if (!coverImageBuffer || !Buffer.isBuffer(coverImageBuffer)) {
152
+ throw new Error('coverImageBuffer is required and must be a Buffer');
153
+ }
154
+ if (!existsSync(cloneDir)) throw new Error(`Clone directory not found: ${cloneDir}`);
155
+
156
+ const projectDir = join(cloneDir, project);
157
+ const manifestPath = join(projectDir, 'manifest.json');
158
+ if (!existsSync(manifestPath)) {
159
+ throw new Error(`No manifest found for project "${project}" at ${manifestPath}`);
160
+ }
161
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
162
+ if (!manifest || !Array.isArray(manifest.entries)) {
163
+ throw new Error(`Malformed manifest at ${manifestPath}: expected { "entries": [...] }.`);
164
+ }
165
+
166
+ const idx = manifest.entries.findIndex(
167
+ (e) => e && (e.version === slug || (e.file && e.file.replace(/\.md$/, '') === slug))
168
+ );
169
+ if (idx === -1) {
170
+ throw new Error(`No manifest row for ${project}/${slug} — cannot add a cover to an entry that doesn't exist.`);
171
+ }
172
+ const entry = manifest.entries[idx];
173
+
174
+ if (entry.cover && !force) {
175
+ throw new Error(`${project}/${slug} already has a cover — pass force: true to overwrite.`);
176
+ }
177
+
178
+ const coverFile = `${slug}.png`;
179
+ const destPath = join(projectDir, coverFile);
180
+ mkdirSync(projectDir, { recursive: true });
181
+
182
+ let written;
183
+ if (force) {
184
+ writeFileSync(destPath, coverImageBuffer);
185
+ written = destPath;
186
+ } else if (existsSync(destPath) && isValidPng(destPath)) {
187
+ // Adopt the existing file as the completed result of a hypothetical interrupted prior
188
+ // write. coverImageBuffer (the staging-dir source the caller already read — a
189
+ // different file from this clone-destination path) is NOT rewritten over it.
190
+ written = destPath;
191
+ } else {
192
+ writeFileSync(destPath, coverImageBuffer);
193
+ written = destPath;
194
+ }
195
+
196
+ const bytes = statSync(written).size;
197
+ manifest.entries[idx] = { ...entry, cover: { file: coverFile, bytes } };
198
+ atomicWriteJSON(manifestPath, manifest);
199
+
200
+ return { written, manifestUpdated: true };
89
201
  }
@@ -0,0 +1,251 @@
1
+ // Rasterize a Claude-composed HTML/CSS cover into a fixed-size PNG.
2
+ //
3
+ // This is the one genuinely deterministic, testable function in the cover-generation
4
+ // path — composing the HTML itself is agent behavior, not a library call (see SKILL.md
5
+ // Step 5 / devlog cover-context). Uses headless Chromium (the `playwright` package,
6
+ // never `playwright-core` — the CLI needs the full package so
7
+ // `npx playwright install chromium` works).
8
+ import { chromium } from 'playwright';
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+ import sharp from 'sharp';
13
+
14
+ export const COVER_FONT_FAMILY = 'DevlogCoverFont';
15
+ export const DEFAULT_RENDER_TIMEOUT_MS = 15000;
16
+ const FONT_PATH = join(homedir(), '.claude', 'skills', 'devlog', 'image-style', 'font.ttf');
17
+ const QUANTIZE_TARGET_BYTES = 500 * 1024;
18
+
19
+ // Fixed hero-zone bounding box on the 1600x900 canvas — the single source of truth this
20
+ // design's prose (image-style/style-guide.example.md) must state identically, checked by
21
+ // tests/skill_contract.test.mjs's COVER-Q-2 invariant rather than trusted to manual review.
22
+ export const HERO_ZONE = { x: 150, y: 425, width: 1300, height: 400 };
23
+ export const HERO_GRID_UNIT = 25;
24
+ // getBoundingClientRect() subpixel/rounding tolerance — not a meaningful size/position
25
+ // allowance. Exact-match (within this tolerance), never containment: a containment check
26
+ // would let an agent draw a tiny #hero-zone in a corner and trivially clear the
27
+ // catalog-overlap check below, since a tiny box is still "contained" in the larger one.
28
+ const HERO_ZONE_TOLERANCE_PX = 2;
29
+
30
+ // Deterministic Node code, never agent-authored text: reads the installed font file and
31
+ // builds a base64 data URI. The font's bytes never pass through Claude's own text
32
+ // generation — a qualitatively different (and much less reliable, at this size) operation
33
+ // than Claude directly viewing reference cover images.
34
+ function readFontBase64(fontPath) {
35
+ if (!existsSync(fontPath)) {
36
+ throw new Error(`Cover font not found at ${fontPath} — run \`devlog init\` to install it.`);
37
+ }
38
+ let bytes;
39
+ try {
40
+ bytes = readFileSync(fontPath);
41
+ } catch (e) {
42
+ throw new Error(`Cover font at ${fontPath} could not be read: ${e.message}`);
43
+ }
44
+ if (bytes.length === 0) {
45
+ throw new Error(`Cover font at ${fontPath} is empty (0 bytes) — reinstall it with \`devlog init\`.`);
46
+ }
47
+ return bytes.toString('base64');
48
+ }
49
+
50
+ // Lossy palette quantization toward a ~300-500KB target. Best-effort: never throws for
51
+ // size reasons, just returns the smallest of the attempts tried. Quantization affects
52
+ // color depth/file size only, never pixel dimensions.
53
+ async function quantize(pngBuffer) {
54
+ const attempts = [
55
+ { palette: true, quality: 90, effort: 8 },
56
+ { palette: true, quality: 70, colors: 128, effort: 8 },
57
+ { palette: true, quality: 50, colors: 64, effort: 8 },
58
+ ];
59
+ let best = pngBuffer;
60
+ for (const opts of attempts) {
61
+ let out;
62
+ try {
63
+ out = await sharp(pngBuffer).png(opts).toBuffer();
64
+ } catch {
65
+ continue; // this attempt's options weren't accepted; fall through to the next
66
+ }
67
+ if (out.length < best.length) best = out;
68
+ if (out.length <= QUANTIZE_TARGET_BYTES) return out;
69
+ }
70
+ return best;
71
+ }
72
+
73
+ // Two rects overlap only on positive-area intersection — rects that merely touch along an
74
+ // edge (zero-area overlap) do NOT count as intersecting.
75
+ function rectsOverlap(a, b) {
76
+ const left = Math.max(a.x, b.x);
77
+ const right = Math.min(a.x + a.width, b.x + b.width);
78
+ const top = Math.max(a.y, b.y);
79
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
80
+ return right > left && bottom > top;
81
+ }
82
+
83
+ function withinTolerance(rect, fixed, toleranceExclusivePx) {
84
+ return (
85
+ Math.abs(rect.x - fixed.x) <= toleranceExclusivePx &&
86
+ Math.abs(rect.y - fixed.y) <= toleranceExclusivePx &&
87
+ Math.abs(rect.width - fixed.width) <= toleranceExclusivePx &&
88
+ Math.abs(rect.height - fixed.height) <= toleranceExclusivePx
89
+ );
90
+ }
91
+
92
+ /**
93
+ * @param {import('playwright').Page} page
94
+ * @returns {Promise<{overlaps: boolean, offendingIcons: string[]}>}
95
+ *
96
+ * #hero-zone is structurally mandatory, not an opt-in marker: throws if querySelectorAll
97
+ * finds zero elements (missing) or more than one (duplicate) — rather than silently
98
+ * skipping the check or resolving to the first DOM match. Once exactly one #hero-zone is
99
+ * confirmed, its rect is compared against the fixed HERO_ZONE constant (exact-match within
100
+ * HERO_ZONE_TOLERANCE_PX, not containment — see the constant's own comment) and throws a
101
+ * distinct geometry-mismatch error if it's outside tolerance, BEFORE computing catalog-icon
102
+ * overlap. Only past both of those checks does this function resolve normally to
103
+ * { overlaps, offendingIcons } — overlap-found and overlap-not-found are both successful
104
+ * resolutions of the check; it is the caller (renderCoverImage) that decides whether
105
+ * overlaps: true itself becomes a thrown error.
106
+ */
107
+ export async function checkHeroZoneOverlap(page) {
108
+ const heroZoneRects = await page.evaluate(() =>
109
+ [...document.querySelectorAll('#hero-zone')].map((el) => {
110
+ const r = el.getBoundingClientRect();
111
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
112
+ })
113
+ );
114
+
115
+ if (heroZoneRects.length === 0) {
116
+ throw new Error('renderCoverImage: composed HTML has no #hero-zone element — a hero zone marker is required, not optional.');
117
+ }
118
+ if (heroZoneRects.length > 1) {
119
+ throw new Error(`renderCoverImage: composed HTML has ${heroZoneRects.length} elements sharing the #hero-zone id — exactly one is required.`);
120
+ }
121
+
122
+ const heroZoneRect = heroZoneRects[0];
123
+ if (!withinTolerance(heroZoneRect, HERO_ZONE, HERO_ZONE_TOLERANCE_PX)) {
124
+ throw new Error(
125
+ `renderCoverImage: #hero-zone rect (x:${heroZoneRect.x} y:${heroZoneRect.y} width:${heroZoneRect.width} height:${heroZoneRect.height}) ` +
126
+ `does not match the fixed HERO_ZONE box (x:${HERO_ZONE.x} y:${HERO_ZONE.y} width:${HERO_ZONE.width} height:${HERO_ZONE.height}) ` +
127
+ `within ${HERO_ZONE_TOLERANCE_PX}px tolerance.`
128
+ );
129
+ }
130
+
131
+ const iconRects = await page.evaluate(() =>
132
+ [...document.querySelectorAll('[data-catalog-icon]')].map((el) => {
133
+ const r = el.getBoundingClientRect();
134
+ return { name: el.getAttribute('data-catalog-icon'), x: r.x, y: r.y, width: r.width, height: r.height };
135
+ })
136
+ );
137
+
138
+ const offendingIcons = iconRects.filter((icon) => rectsOverlap(icon, heroZoneRect)).map((icon) => icon.name);
139
+ return { overlaps: offendingIcons.length > 0, offendingIcons };
140
+ }
141
+
142
+ /**
143
+ * @param {string} html full, self-contained HTML document (must start with <!DOCTYPE html>)
144
+ * @param {{width:number, height:number, timeoutMs?:number, fontPath?:string, executablePath?:string}} opts
145
+ * @returns {Promise<Buffer>} PNG bytes, exactly {width}x{height} pixels
146
+ *
147
+ * Throws on exactly four realistic failure modes: a render timeout; Chromium not being
148
+ * installed; a missing/unreadable installed font file; and (a deliberate widening of this
149
+ * already-documented throw contract) a #hero-zone structural/geometry problem — missing
150
+ * #hero-zone, duplicate #hero-zone, the #hero-zone rect not matching the fixed HERO_ZONE
151
+ * bounding box within tolerance, or a catalog icon overlapping the hero zone. Does NOT
152
+ * throw on malformed HTML — Chromium's HTML5 parser is deliberately fault-tolerant and
153
+ * recovers into some DOM regardless of input; a poorly composed document renders wrong, it
154
+ * doesn't fail to render.
155
+ */
156
+ export async function renderCoverImage(html, opts = {}) {
157
+ const {
158
+ width,
159
+ height,
160
+ timeoutMs = DEFAULT_RENDER_TIMEOUT_MS,
161
+ fontPath = FONT_PATH,
162
+ executablePath,
163
+ // Test-only seam, not part of the documented contract: lets tests inject a fake
164
+ // launch() to spy on page.setContent/addStyleTag/evaluate/screenshot call order
165
+ // without spinning up real Chromium. Defaults to the real playwright launcher.
166
+ launch = executablePath ? (o) => chromium.launch({ ...o, executablePath }) : (o) => chromium.launch(o),
167
+ } = opts;
168
+
169
+ if (typeof html !== 'string' || html.trim() === '') {
170
+ throw new Error('renderCoverImage: html must be a non-empty string');
171
+ }
172
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
173
+ throw new Error('renderCoverImage: width/height must be positive integers');
174
+ }
175
+
176
+ // Read + validate the font BEFORE ever launching Chromium — a missing/corrupt font is a
177
+ // plain, cheap filesystem check and shouldn't cost a browser launch to detect.
178
+ const fontBase64 = readFontBase64(fontPath);
179
+
180
+ let browser;
181
+ try {
182
+ browser = await launch(undefined);
183
+ } catch (e) {
184
+ throw new Error(
185
+ `Chromium is not installed (or failed to launch) — run \`npx playwright install chromium\`. (${e.message})`
186
+ );
187
+ }
188
+
189
+ try {
190
+ const page = await browser.newPage({ viewport: { width, height } });
191
+
192
+ // Parse Claude's document exactly as authored first, in standards mode — never a raw
193
+ // string prepend of the font-face rule, which would force quirks mode (per the HTML5
194
+ // tree-construction algorithm, any non-whitespace content before the DOCTYPE token
195
+ // does) and risks a same-specificity shadow from Claude's own CSS.
196
+ await page.setContent(html, { waitUntil: 'load', timeout: timeoutMs });
197
+
198
+ // Inject the real font into the already-parsed document via the DOM API. Landing here
199
+ // — added after the page's own stylesheets are already parsed — also means this rule
200
+ // wins any cascade tie against markup referencing the same family, by document order.
201
+ const fontFaceCss =
202
+ `@font-face { font-family: '${COVER_FONT_FAMILY}'; ` +
203
+ `src: url(data:font/ttf;base64,${fontBase64}) format('truetype'); }`;
204
+ await page.addStyleTag({ content: fontFaceCss });
205
+
206
+ // Merely declaring @font-face does not synchronously start the load — Chromium only
207
+ // triggers the fetch/decode once a style-recalc discovers text resolving to that
208
+ // family, and that recalc isn't guaranteed to have run yet. document.fonts.load()
209
+ // explicitly kicks off the load so the immediately-following document.fonts.ready
210
+ // check is guaranteed to cover it, closing a real race where the ready-promise could
211
+ // otherwise resolve before the embedded font has actually finished decoding.
212
+ const fontsReady = (async () => {
213
+ await page.evaluate((family) => document.fonts.load(`1em '${family}'`), COVER_FONT_FAMILY);
214
+ await page.evaluate(() => document.fonts.ready);
215
+ })();
216
+ // Attach a no-op handler immediately so a late rejection (e.g. the timeout branch below
217
+ // wins the race, then this promise itself rejects after the page is torn down) never
218
+ // surfaces as an unhandled rejection — the race below is still driven by this same
219
+ // promise reference.
220
+ fontsReady.catch(() => {});
221
+ await Promise.race([
222
+ fontsReady,
223
+ new Promise((_, reject) =>
224
+ setTimeout(
225
+ () => reject(new Error(`renderCoverImage: timed out after ${timeoutMs}ms waiting on font load`)),
226
+ timeoutMs
227
+ )
228
+ ),
229
+ ]);
230
+
231
+ // Geometry-enforced hero-zone guard, immediately before the screenshot: throws on a
232
+ // missing/duplicate #hero-zone, a #hero-zone rect that doesn't match the fixed
233
+ // HERO_ZONE box, or (below) a catalog icon whose rect overlaps the hero zone.
234
+ const { overlaps, offendingIcons } = await checkHeroZoneOverlap(page);
235
+ if (overlaps) {
236
+ throw new Error(
237
+ `renderCoverImage: catalog icon(s) [${offendingIcons.join(', ')}] overlaps hero zone — ` +
238
+ 'catalog icons may only appear as an accent glyph outside #hero-zone, never inside it.'
239
+ );
240
+ }
241
+
242
+ // Viewport-clipped screenshot (fullPage omitted/false, Playwright's default) — never
243
+ // fullPage: true, which would capture the whole scrollable page rather than just the
244
+ // viewport. This is what guarantees the output is always exactly {width, height}
245
+ // regardless of whether the composed HTML's content overflows it.
246
+ const png = await page.screenshot({ type: 'png', timeout: timeoutMs });
247
+ return await quantize(png);
248
+ } finally {
249
+ await browser.close();
250
+ }
251
+ }
package/lib/scan.mjs CHANGED
@@ -74,6 +74,7 @@ export function scanProject(project, { branch = 'main', fetch = true, existingFi
74
74
  key: project.key,
75
75
  label: project.label || project.key,
76
76
  remote: project.remote,
77
+ private: !!project.private,
77
78
  path: project.path,
78
79
  pathFilter: project.pathFilter || null,
79
80
  tagPrefix: project.tagPrefix || 'v',
@@ -107,6 +108,11 @@ export function scanProject(project, { branch = 'main', fetch = true, existingFi
107
108
  const hasPublishedRef = git(project.path, ['rev-parse', '--verify', '--quiet', publishedRef]) !== null;
108
109
 
109
110
  const isPublic = (rev) => {
111
+ // A private project has no safe commit surface, full stop — this bypasses
112
+ // remoteMatches/hasPublishedRef entirely rather than relying on them to
113
+ // happen to be false, since a private repo can still have origin configured
114
+ // correctly (remoteMatches true) and a normally-pushed branch.
115
+ if (project.private) return false;
110
116
  if (!remoteMatches || !hasPublishedRef) return false;
111
117
  return spawnArgs('git', ['-C', project.path, 'merge-base', '--is-ancestor', rev, publishedRef]).status === 0;
112
118
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natjswenson/devlog",
3
- "version": "0.6.0",
3
+ "version": "0.9.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",
@@ -33,6 +33,7 @@
33
33
  "preview/",
34
34
  "examples/",
35
35
  "voice/",
36
+ "image-style/",
36
37
  "SKILL.md",
37
38
  "SECURITY.md",
38
39
  "CHANGELOG.md",
@@ -50,11 +51,13 @@
50
51
  "dependencies": {
51
52
  "@vitejs/plugin-react": "6.0.1",
52
53
  "kleur": "4.1.5",
54
+ "playwright": "1.61.1",
53
55
  "prompts": "2.4.2",
54
56
  "react": "18.3.1",
55
57
  "react-dom": "18.3.1",
56
58
  "react-markdown": "9.1.0",
57
59
  "remark-gfm": "4.0.1",
60
+ "sharp": "0.35.3",
58
61
  "vite": "8.0.16"
59
62
  }
60
63
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "comment": "Prose guardrails in SKILL.md that must survive edits. Each pattern is a case-insensitive regex tested against the full SKILL.md text by tests/skill_contract.test.mjs. If you intentionally change one, update it here in the same commit and say why in the PR.",
2
+ "comment": "Prose guardrails in SKILL.md that must survive edits (the `prose` array — each pattern is a case-insensitive regex tested against the full SKILL.md text) and code-level guards elsewhere in this skill's source (the `code` array — each pattern is tested against the file named by its own `file` field, not SKILL.md). Both are checked by tests/skill_contract.test.mjs. If you intentionally change one, update it here in the same commit and say why in the PR.",
3
3
  "prose": [
4
4
  {
5
5
  "id": "immutable-entries",
@@ -60,7 +60,40 @@
60
60
  "id": "no-push-retry",
61
61
  "pattern": "do not retry automatically",
62
62
  "rationale": "A failed push is surfaced to the user, not retried blind."
63
+ },
64
+ {
65
+ "id": "private-project-no-links",
66
+ "pattern": "no post ever[\\s\\S]{0,10}links a commit for it",
67
+ "rationale": "A private project's commits must never scan as public just because remoteMatches + branch-contains happen to hold — losing this line reopens the dead-link-to-a-private-repo path."
68
+ },
69
+ {
70
+ "id": "cover-composition-scope",
71
+ "pattern": "never the raw draft file, never[\\s\\S]{0,10}## Changelog",
72
+ "rationale": "Cover composition must draw only from title/tags/summary/## Shipped — never the raw draft or Changelog — losing this line reopens off-scope content leaking into an auto-composed image."
73
+ },
74
+ {
75
+ "id": "cover-never-blocks-publish",
76
+ "pattern": "[Nn]ever block[\\s\\S]{0,10}publish on a missing style guide",
77
+ "rationale": "A missing/uninstalled style guide must degrade gracefully (no cover this run), never abort the whole publish."
78
+ },
79
+ {
80
+ "id": "cover-review-gate",
81
+ "pattern": "this interactive review IS the quality gate for the cover",
82
+ "rationale": "The rendered cover must be shown to the user before push, the same way Step 4 gates the prose — losing this line reopens publishing an unreviewed cover."
83
+ },
84
+ {
85
+ "id": "cover-custom-illustration",
86
+ "pattern": "cover that just re-renders the title in large text is a failure",
87
+ "rationale": "First shipped version of this feature produced a shared text-heavy template with a rotating stock shape — rejected as bland/repetitive. Losing this line reopens that regression. Independent of, not superseded by, the v0.9.0 geometry guard (cover-catalog-hero-overlap-guard, below): that guard is a mechanical check on an agent's rendered composition each time a cover is rendered; this line is a prose guardrail against a future SKILL.md/style-guide edit silently reintroducing the bland-template regression at the instruction level. Two different regression surfaces, both still worth guarding."
88
+ }
89
+ ],
90
+ "code": [
91
+ {
92
+ "id": "cover-catalog-hero-overlap-guard",
93
+ "file": "lib/render_cover.mjs",
94
+ "pattern": "(?:getBoundingClientRect[\\s\\S]{0,400}hero-zone|hero-zone[\\s\\S]{0,400}getBoundingClientRect)",
95
+ "rationale": "The catalog-icon/hero-zone overlap check must stay wired into renderCoverImage() — losing it silently reopens the gap where a catalog icon (or two, connected by a line) can stand in for the required bespoke hero illustration."
63
96
  }
64
97
  ],
65
- "cli_commands_referenced": ["scan", "lint-post", "publish-entry", "add-project", "remove-project", "set", "config", "init"]
98
+ "cli_commands_referenced": ["scan", "lint-post", "publish-entry", "add-project", "remove-project", "set", "config", "init", "cover-context", "render-cover"]
66
99
  }