@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,71 @@
1
+ // Mechanical extraction of a post's fenced code blocks, in order, so the
2
+ // SKILL.md Step 4 assemble-and-run check is a command instead of an honor
3
+ // system: the audit of the first six runs found "copy the blocks into a
4
+ // scratch dir and run them" was skipped whenever it was inconvenient, and two
5
+ // posts shipped claiming "real output" over code that could not run.
6
+ // `text` fences are expected OUTPUT, not code — they're listed but not written
7
+ // as runnable files.
8
+ import { writeFileSync, mkdirSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { parseFrontmatter } from './lint_post.mjs';
11
+
12
+ // Languages a post realistically fences; anything unknown falls back to .txt
13
+ // so the block is still on disk for the agent to run by hand.
14
+ const LANG_EXT = {
15
+ javascript: 'js', js: 'js', mjs: 'mjs', typescript: 'ts', ts: 'ts', jsx: 'jsx', tsx: 'tsx',
16
+ python: 'py', py: 'py',
17
+ bash: 'sh', sh: 'sh', shell: 'sh', zsh: 'sh',
18
+ json: 'json', yaml: 'yml', yml: 'yml', toml: 'toml',
19
+ html: 'html', css: 'css', svg: 'svg', xml: 'xml',
20
+ sql: 'sql', ruby: 'rb', go: 'go', rust: 'rs', java: 'java', c: 'c', cpp: 'cpp',
21
+ markdown: 'md', md: 'md', diff: 'diff', ini: 'ini', dockerfile: 'dockerfile', makefile: 'mk',
22
+ };
23
+
24
+ const OUTPUT_LANGS = new Set(['text', 'txt', 'console', 'output']);
25
+
26
+ // Ordered fenced blocks of a post body (frontmatter excluded):
27
+ // [{ index, lang, code, runnable }] — `runnable` is false for output-shaped
28
+ // fences (`text` and friends), which readers compare against, not execute.
29
+ export function assemblePost(content) {
30
+ const { body } = parseFrontmatter(content);
31
+ const blocks = [];
32
+ let current = null;
33
+ for (const line of body.split('\n')) {
34
+ const m = /^```(.*)$/.exec(line);
35
+ if (m && !current) {
36
+ current = { lang: m[1].trim().toLowerCase() || 'txt', lines: [] };
37
+ } else if (m && current) {
38
+ const lang = current.lang;
39
+ blocks.push({
40
+ index: blocks.length + 1,
41
+ lang,
42
+ code: current.lines.join('\n'),
43
+ runnable: !OUTPUT_LANGS.has(lang),
44
+ });
45
+ current = null;
46
+ } else if (current) {
47
+ current.lines.push(line);
48
+ }
49
+ }
50
+ return blocks;
51
+ }
52
+
53
+ // Write the runnable blocks to outDir as NN.<ext> and return a manifest of
54
+ // everything (including the skipped output blocks) for the agent to execute
55
+ // in order.
56
+ export function writeAssembledBlocks(content, outDir) {
57
+ const blocks = assemblePost(content);
58
+ mkdirSync(outDir, { recursive: true });
59
+ const written = blocks.map((b) => {
60
+ if (!b.runnable) return { ...b, file: null };
61
+ const ext = LANG_EXT[b.lang] || 'txt';
62
+ const file = join(outDir, `${String(b.index).padStart(2, '0')}.${ext}`);
63
+ writeFileSync(file, b.code.endsWith('\n') || b.code === '' ? b.code : `${b.code}\n`);
64
+ return { index: b.index, lang: b.lang, runnable: true, file };
65
+ });
66
+ return {
67
+ blocks: written.map(({ code, ...rest }) => rest),
68
+ runnableCount: written.filter((b) => b.runnable).length,
69
+ outputBlockCount: written.filter((b) => !b.runnable).length,
70
+ };
71
+ }
package/lib/cover_gen.mjs CHANGED
@@ -77,7 +77,12 @@ export function mergeManifestEntries(cloneDir, config) {
77
77
  for (const p of (config.projects || [])) {
78
78
  const { entries, status, reason } = readProjectManifest(cloneDir, p.key);
79
79
  if (status === 'failed') throw new Error(reason);
80
- for (const e of entries) merged.push({ ...e, project: p.key });
80
+ for (const e of entries) {
81
+ // Tombstoned rows (removed: true) are editorial retirements, not entries —
82
+ // they must never surface as backfill candidates or reference covers.
83
+ if (e && e.removed) continue;
84
+ merged.push({ ...e, project: p.key });
85
+ }
81
86
  }
82
87
  return merged;
83
88
  }
package/lib/lint_post.mjs CHANGED
@@ -110,8 +110,42 @@ export function extractSourceUrls(sectionContent) {
110
110
  return urls;
111
111
  }
112
112
 
113
+ // Voice-contract bans that are safe to check deterministically (the fuller
114
+ // contract — hedge words, staccato rhythm, closers — stays with the judge,
115
+ // where context can tell a false positive from a violation). Phrases are the
116
+ // user's own explicit bans from voice-notes.md.
117
+ export const VOICE_BANNED_PHRASES = [
118
+ /\bhonestly,/i,
119
+ /\bI keep seeing\b/i,
120
+ /isn't a bug, it's/i,
121
+ /not a bug, a feature/i,
122
+ /\bthe problem isn't\b/i,
123
+ /here's what stuck with me/i,
124
+ ];
125
+
126
+ // Sections whose text is template punctuation or verbatim quoted data, exempt
127
+ // from voice rules per SKILL.md (the `## Sources` em dash is fixed template
128
+ // punctuation; `## Changelog` quotes commit subjects as-is).
129
+ const VOICE_EXEMPT_SECTIONS = new Set(['Sources', 'Changelog']);
130
+
131
+ // Prose lines of the non-exempt sections: fenced code excluded.
132
+ function voiceCheckableLines(sections) {
133
+ const out = [];
134
+ for (const s of sections) {
135
+ if (VOICE_EXEMPT_SECTIONS.has(s.heading)) continue;
136
+ let inFence = false;
137
+ for (const line of s.content.split('\n')) {
138
+ if (/^```/.test(line)) { inFence = !inFence; continue; }
139
+ if (!inFence) out.push({ heading: s.heading, line });
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
113
145
  // Lint a post. Returns { ok, findings: [{ rule, message }] }.
114
- export function lintPost(content, { minSources = 3, filename = null } = {}) {
146
+ // `voice: true` adds the deterministic voice-contract rules opt-in so the
147
+ // eval harness and non-voice callers keep their existing behavior.
148
+ export function lintPost(content, { minSources = 3, filename = null, voice = false } = {}) {
115
149
  const findings = [];
116
150
  const add = (rule, message) => findings.push({ rule, message });
117
151
 
@@ -194,5 +228,17 @@ export function lintPost(content, { minSources = 3, filename = null } = {}) {
194
228
  add('fence-untagged', `Code fence at body line ${line} has no language tag.`);
195
229
  }
196
230
 
231
+ if (voice) {
232
+ for (const { heading, line } of voiceCheckableLines(sections)) {
233
+ if (line.includes('—')) {
234
+ add('voice-em-dash', `Em dash in \`## ${heading}\` prose ("${line.trim().slice(0, 60)}…") — the voice contract bans them; use a comma, semicolon, or split the sentence.`);
235
+ }
236
+ for (const re of VOICE_BANNED_PHRASES) {
237
+ const m = re.exec(line);
238
+ if (m) add('voice-banned-phrase', `Banned phrase "${m[0]}" in \`## ${heading}\` — rewrite per voice-notes.md.`);
239
+ }
240
+ }
241
+ }
242
+
197
243
  return { ok: findings.length === 0, findings };
198
244
  }
@@ -5,7 +5,7 @@
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, statSync, openSync, readSync, closeSync, readdirSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { RE_PROJECT_KEY, RE_FINAL_RELEASE, atomicWriteJSON } from './core.mjs';
8
- import { parseFrontmatter } from './lint_post.mjs';
8
+ import { parseFrontmatter, splitSections } from './lint_post.mjs';
9
9
 
10
10
  const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
11
11
 
@@ -55,6 +55,81 @@ function sortEntries(entries) {
55
55
  String(b.date).localeCompare(String(a.date)) || compareVersionsDesc(a, b));
56
56
  }
57
57
 
58
+ // Tombstoned rows have no date, so they'd sort arbitrarily among the live
59
+ // feed rows — keep the live entries date-sorted and park tombstones at the end.
60
+ function sortManifestEntries(entries) {
61
+ const live = entries.filter((e) => !(e && e.removed));
62
+ const removed = entries.filter((e) => e && e.removed);
63
+ return [...sortEntries(live), ...removed];
64
+ }
65
+
66
+ function readManifestIfExists(manifestPath) {
67
+ if (!existsSync(manifestPath)) return { entries: [] };
68
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
69
+ if (!manifest || !Array.isArray(manifest.entries)) {
70
+ throw new Error(`Malformed manifest at ${manifestPath}: expected { "entries": [...] }.`);
71
+ }
72
+ return manifest;
73
+ }
74
+
75
+ // Commit hashes referenced by a post's `## Changelog` section, normalized to
76
+ // their 7-char short form so a short link text matches its full-hash URL.
77
+ // Only hash-shaped tokens in link syntax count — `[abc1234](...)` texts and
78
+ // `/commit/<hash>` URLs — never bare hex words in prose.
79
+ export function extractChangelogHashes(body) {
80
+ const section = splitSections(body).find((s) => s.heading === 'Changelog');
81
+ const hashes = new Set();
82
+ if (!section) return hashes;
83
+ for (const m of section.content.matchAll(/\[([0-9a-f]{7,40})\]/g)) hashes.add(m[1].slice(0, 7));
84
+ for (const m of section.content.matchAll(/\/commit\/([0-9a-f]{7,40})\b/g)) hashes.add(m[1].slice(0, 7));
85
+ return hashes;
86
+ }
87
+
88
+ // A commit belongs to exactly one post's Changelog (SKILL.md 3b) — in a
89
+ // monorepo, one commit range can feed several projects' releases, and letting
90
+ // both posts list it produced twin entries with identical Changelogs. Walks
91
+ // every live published entry in the clone and throws on the first collision.
92
+ function assertNoChangelogCollision(cloneDir, project, version, draftBody) {
93
+ const draftHashes = extractChangelogHashes(draftBody);
94
+ if (draftHashes.size === 0) return;
95
+
96
+ let dirents;
97
+ try {
98
+ dirents = readdirSync(cloneDir, { withFileTypes: true });
99
+ } catch {
100
+ return;
101
+ }
102
+ for (const dirent of dirents) {
103
+ if (!dirent.isDirectory()) continue;
104
+ let manifest;
105
+ try {
106
+ manifest = readManifestIfExists(join(cloneDir, dirent.name, 'manifest.json'));
107
+ } catch {
108
+ continue; // a sibling project's broken manifest must not block this publish
109
+ }
110
+ for (const entry of manifest.entries) {
111
+ if (!entry || entry.removed || !entry.file) continue;
112
+ if (dirent.name === project && entry.version === version) continue; // self (idempotent republish)
113
+ const entryPath = join(cloneDir, dirent.name, entry.file);
114
+ if (!existsSync(entryPath)) continue;
115
+ let published;
116
+ try {
117
+ published = parseFrontmatter(readFileSync(entryPath, 'utf8'));
118
+ } catch {
119
+ continue;
120
+ }
121
+ for (const hash of extractChangelogHashes(published.body)) {
122
+ if (draftHashes.has(hash)) {
123
+ throw new Error(
124
+ `Commit ${hash} already appears in ${dirent.name}/${entry.file}'s Changelog — ` +
125
+ `a commit belongs to exactly one post's Changelog (SKILL.md 3b); drop it from this draft's Changelog.`
126
+ );
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+
58
133
  // `no` is a single sequence across ALL projects (issue numbers of one
59
134
  // publication, not per-project counters), but manifests are stored one per
60
135
  // project — so "next" means "scan every project's manifest under cloneDir and
@@ -104,16 +179,33 @@ export function publishEntry({ cloneDir, project, version, entryPath, coverImage
104
179
 
105
180
  const projectDir = join(cloneDir, project);
106
181
  const destPath = join(projectDir, `${version}.md`);
182
+ const manifestPath = join(projectDir, 'manifest.json');
183
+ const manifest = readManifestIfExists(manifestPath);
184
+
185
+ // Tombstone refusal comes before the file check: a tombstoned release's .md
186
+ // is gone by definition, and re-generating it is exactly the failure this
187
+ // state exists to prevent (an editorially moved/consolidated entry must
188
+ // never come back on a later run).
189
+ const tombstoned = manifest.entries.find((e) => e && e.removed && e.version === version);
190
+ if (tombstoned) {
191
+ throw new Error(
192
+ `Entry ${project}/${version} is tombstoned` +
193
+ (tombstoned.reason ? ` (${tombstoned.reason})` : '') +
194
+ ' — this release was editorially retired, refusing to republish.'
195
+ );
196
+ }
107
197
  if (existsSync(destPath)) {
108
198
  throw new Error(`Entry ${project}/${version}.md already exists — a cut release is immutable, refusing to overwrite.`);
109
199
  }
110
200
 
111
201
  const content = readFileSync(entryPath, 'utf8');
112
- const { data } = parseFrontmatter(content);
202
+ const { data, body } = parseFrontmatter(content);
113
203
  if (!data || !data.title || !data.date || !data.summary) {
114
204
  throw new Error('Entry frontmatter must include title, date, and summary (run lint-post first).');
115
205
  }
116
206
 
207
+ assertNoChangelogCollision(cloneDir, project, version, body);
208
+
117
209
  mkdirSync(projectDir, { recursive: true });
118
210
  copyFileSync(entryPath, destPath);
119
211
 
@@ -128,15 +220,6 @@ export function publishEntry({ cloneDir, project, version, entryPath, coverImage
128
220
  writeFileSync(join(projectDir, coverFile), coverImageBuffer);
129
221
  }
130
222
 
131
- const manifestPath = join(projectDir, 'manifest.json');
132
- let manifest = { entries: [] };
133
- if (existsSync(manifestPath)) {
134
- manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
135
- if (!manifest || !Array.isArray(manifest.entries)) {
136
- throw new Error(`Malformed manifest at ${manifestPath}: expected { "entries": [...] }.`);
137
- }
138
- }
139
-
140
223
  const file = `${version}.md`;
141
224
  // Idempotent: legacy manifests may already reference this file/version even
142
225
  // when the .md was missing — never duplicate an index row.
@@ -159,7 +242,7 @@ export function publishEntry({ cloneDir, project, version, entryPath, coverImage
159
242
  no,
160
243
  ...(coverFile ? { cover: { file: coverFile, bytes: coverImageBuffer.length } } : {}),
161
244
  });
162
- manifest.entries = sortEntries(manifest.entries);
245
+ manifest.entries = sortManifestEntries(manifest.entries);
163
246
  atomicWriteJSON(manifestPath, manifest);
164
247
  manifestUpdated = true;
165
248
  }
@@ -167,6 +250,120 @@ export function publishEntry({ cloneDir, project, version, entryPath, coverImage
167
250
  return { written: destPath, manifestUpdated, coverWritten: !!coverFile, no };
168
251
  }
169
252
 
253
+ // Editorially retire a release: after an entry is manually moved, consolidated,
254
+ // or deleted in the target repo, its (project, version) identity must keep
255
+ // suppressing generation forever — scan reports it as `entry-tombstoned` and
256
+ // publish-entry refuses it. Creates the project manifest if the whole directory
257
+ // was removed (the market-research case). Refuses to tombstone a LIVE entry
258
+ // (its .md still on disk): move or delete the entry first, deliberately, then
259
+ // tombstone the identity it left behind.
260
+ export function tombstoneEntry({ cloneDir, project, version, reason }) {
261
+ if (!RE_PROJECT_KEY.test(project) || project.includes('..')) {
262
+ throw new Error(`Invalid project key: ${JSON.stringify(project)}`);
263
+ }
264
+ if (!RE_FINAL_RELEASE.test(version)) {
265
+ throw new Error(`Invalid version label (must be v<digits.digits...>): ${JSON.stringify(version)}`);
266
+ }
267
+ if (typeof reason !== 'string' || reason.trim() === '' || /[\x00-\x1f]/.test(reason)) {
268
+ throw new Error('A tombstone requires a non-empty --reason (where did the entry go, and why?).');
269
+ }
270
+ if (!existsSync(cloneDir)) throw new Error(`Clone directory not found: ${cloneDir}`);
271
+
272
+ const projectDir = join(cloneDir, project);
273
+ const manifestPath = join(projectDir, 'manifest.json');
274
+ const manifest = readManifestIfExists(manifestPath);
275
+
276
+ const idx = manifest.entries.findIndex((e) => e && e.version === version);
277
+ if (idx !== -1 && manifest.entries[idx].removed) {
278
+ return { tombstoned: false, already: true, project, version };
279
+ }
280
+
281
+ const file = `${version}.md`;
282
+ if (idx !== -1 && existsSync(join(projectDir, manifest.entries[idx].file || file))) {
283
+ throw new Error(
284
+ `${project}/${version} is a live published entry — tombstone marks an identity whose ` +
285
+ 'file was editorially moved or deleted; remove/move the entry file first, then tombstone.'
286
+ );
287
+ }
288
+
289
+ const prior = idx !== -1 ? manifest.entries[idx] : null;
290
+ const row = {
291
+ version,
292
+ file: prior?.file || file,
293
+ removed: true,
294
+ reason: reason.trim(),
295
+ // A dead row that already held a frozen `no` keeps it — numbers are never
296
+ // reused, and dropping the max would let the next publish re-issue it.
297
+ ...(prior && Number.isInteger(prior.no) ? { no: prior.no } : {}),
298
+ };
299
+ if (idx !== -1) manifest.entries[idx] = row;
300
+ else manifest.entries.push(row);
301
+
302
+ mkdirSync(projectDir, { recursive: true });
303
+ manifest.entries = sortManifestEntries(manifest.entries);
304
+ atomicWriteJSON(manifestPath, manifest);
305
+ return { tombstoned: true, project, version, manifest: manifestPath };
306
+ }
307
+
308
+ // Post-publish metadata resync: entry prose edits are the user's call, but the
309
+ // manifest's title/summary/date/tags (what the site index, RSS, and covers
310
+ // read) previously had no legitimate way to follow — a hand-edited post left
311
+ // them stale for hours while "rebuilds" chased phantom caches. Reads the
312
+ // PUBLISHED .md in the clone and replaces exactly those four fields on its
313
+ // manifest row. Never touches `no`, `version`, `file`, or `cover`; reports
314
+ // `coverStale` so the caller knows a cover derived from the old title may need
315
+ // regenerating.
316
+ export function syncEntryFromFrontmatter({ cloneDir, project, slug }) {
317
+ if (!RE_PROJECT_KEY.test(project) || project.includes('..')) {
318
+ throw new Error(`Invalid project key: ${JSON.stringify(project)}`);
319
+ }
320
+ assertSafeSlug(slug);
321
+ if (!existsSync(cloneDir)) throw new Error(`Clone directory not found: ${cloneDir}`);
322
+
323
+ const projectDir = join(cloneDir, project);
324
+ const manifestPath = join(projectDir, 'manifest.json');
325
+ if (!existsSync(manifestPath)) {
326
+ throw new Error(`No manifest found for project "${project}" at ${manifestPath}`);
327
+ }
328
+ const manifest = readManifestIfExists(manifestPath);
329
+
330
+ const idx = manifest.entries.findIndex(
331
+ (e) => e && (e.version === slug || (e.file && e.file.replace(/\.md$/, '') === slug))
332
+ );
333
+ if (idx === -1) {
334
+ throw new Error(`No manifest row for ${project}/${slug} — nothing to sync.`);
335
+ }
336
+ const entry = manifest.entries[idx];
337
+ if (entry.removed) {
338
+ throw new Error(`${project}/${slug} is tombstoned — there is no entry to sync.`);
339
+ }
340
+
341
+ const entryFilePath = join(projectDir, entry.file);
342
+ if (!existsSync(entryFilePath)) {
343
+ throw new Error(`Published entry file not found at ${entryFilePath} — sync reads the clone's .md, not a draft.`);
344
+ }
345
+ const { data } = parseFrontmatter(readFileSync(entryFilePath, 'utf8'));
346
+ if (!data || !data.title || !data.date || !data.summary) {
347
+ throw new Error('Published entry frontmatter must include title, date, and summary (run lint-post on it first).');
348
+ }
349
+
350
+ const next = {
351
+ ...entry,
352
+ date: String(data.date),
353
+ title: String(data.title),
354
+ summary: String(data.summary),
355
+ tags: Array.isArray(data.tags) ? data.tags : [],
356
+ };
357
+ const changedFields = ['date', 'title', 'summary', 'tags'].filter(
358
+ (k) => JSON.stringify(entry[k]) !== JSON.stringify(next[k])
359
+ );
360
+ manifest.entries[idx] = next;
361
+ manifest.entries = sortManifestEntries(manifest.entries);
362
+ atomicWriteJSON(manifestPath, manifest);
363
+
364
+ return { synced: true, project, slug, changedFields, coverStale: !!entry.cover };
365
+ }
366
+
170
367
  // Backfill path only: add a cover to an entry that was already published without one.
171
368
  // Never writes/reads <slug>.md, never pushes a new manifest row — its only mutation is the
172
369
  // `cover` field of an already-existing entry, keyed by that entry's version/file stem.
package/lib/scan.mjs CHANGED
@@ -66,10 +66,11 @@ function git(projectPath, args) {
66
66
  return execArgs('git', ['-C', projectPath, ...args]);
67
67
  }
68
68
 
69
- // Scan one project's local clone. Pure git — the caller supplies the set of
70
- // entry filenames that already exist in the target repo (existingFiles), so
71
- // this function stays testable against throwaway fixture repos.
72
- export function scanProject(project, { branch = 'main', fetch = true, existingFiles = new Set() } = {}) {
69
+ // Scan one project's local clone. Pure git — the caller supplies what already
70
+ // exists in the target repo (`existing`: entry filenames, live manifest
71
+ // versions, and tombstoned versions), so this function stays testable against
72
+ // throwaway fixture repos.
73
+ export function scanProject(project, { branch = 'main', fetch = true, existing = emptyExisting() } = {}) {
73
74
  const out = {
74
75
  key: project.key,
75
76
  label: project.label || project.key,
@@ -119,7 +120,14 @@ export function scanProject(project, { branch = 'main', fetch = true, existingFi
119
120
 
120
121
  for (let i = 0; i < releases.length; i++) {
121
122
  const { tag, version } = releases[i];
122
- if (existingFiles.has(`${version}.md`)) {
123
+ // Tombstone check comes first: a tombstoned row also carries a `file`
124
+ // field, so the entry-exists check below would otherwise mask the more
125
+ // specific reason.
126
+ if (existing.removedVersions.has(version)) {
127
+ out.skippedTags.push({ tag, reason: 'entry-tombstoned' });
128
+ continue;
129
+ }
130
+ if (existing.versions.has(version) || existing.files.has(`${version}.md`)) {
123
131
  out.skippedTags.push({ tag, reason: 'entry-exists' });
124
132
  continue;
125
133
  }
@@ -185,22 +193,65 @@ function splitLogLine(line) {
185
193
  return [hash, subject, date];
186
194
  }
187
195
 
188
- // Which entry files already exist in the target repo for one project — a
189
- // single `gh api` directory listing (vs. the old one-probe-per-tag pattern).
190
- // Returns { files: Set, status: 'ok' | 'empty' | 'failed' }: a 404 means the
191
- // project has no entries yet; any other failure is surfaced so the caller
192
- // knows the entry-exists filter may be incomplete (publish-entry still refuses
193
- // overwrites against the fresh clone, so a stale scan cannot clobber anything).
196
+ export function emptyExisting() {
197
+ return { files: new Set(), versions: new Set(), removedVersions: new Set(), entries: [] };
198
+ }
199
+
200
+ // What already exists in the target repo for one project. Entry identity is
201
+ // project+version in the MANIFEST, not a filename: manifest rows survive
202
+ // editorial file moves/consolidations, and a tombstoned row (`removed: true`)
203
+ // keeps suppressing generation even after its .md is gone — the failure class
204
+ // that re-armed three deleted entries in the first six runs. Fetches the
205
+ // project's manifest.json (one `gh api` call); falls back to a directory
206
+ // listing for legacy dirs with entries but no manifest yet.
207
+ // Returns { files: Set, versions: Set, removedVersions: Set,
208
+ // entries: [{version, title, tags}] (live rows only),
209
+ // status: 'ok' | 'empty' | 'failed' }: 'empty' means the project has
210
+ // no entries yet; 'failed' is surfaced so the caller knows the entry-exists
211
+ // filter may be incomplete (publish-entry still refuses overwrites against the
212
+ // fresh clone, so a stale scan cannot clobber anything).
194
213
  export function fetchExistingEntries(targetRepo, branch, projectKey, targetDir = '') {
195
214
  const contentPath = targetDir ? `${targetDir}/${projectKey}` : projectKey;
215
+ const m = spawnArgs('gh', ['api', `repos/${targetRepo}/contents/${contentPath}/manifest.json?ref=${branch}`, '--jq', '.content']);
216
+ if (m.status === 0) {
217
+ let manifest = null;
218
+ try {
219
+ manifest = JSON.parse(Buffer.from(m.stdout.replace(/\s/g, ''), 'base64').toString('utf8'));
220
+ } catch {
221
+ // Malformed manifest content — fall through to the directory listing.
222
+ }
223
+ if (manifest && Array.isArray(manifest.entries)) {
224
+ const out = { ...emptyExisting(), status: 'ok' };
225
+ for (const e of manifest.entries) {
226
+ if (!e) continue;
227
+ if (typeof e.file === 'string') out.files.add(e.file);
228
+ if (typeof e.version !== 'string' || e.version === '') continue;
229
+ if (e.removed) {
230
+ out.removedVersions.add(e.version);
231
+ } else {
232
+ out.versions.add(e.version);
233
+ out.entries.push({
234
+ version: e.version,
235
+ title: typeof e.title === 'string' ? e.title : null,
236
+ tags: Array.isArray(e.tags) ? e.tags : [],
237
+ });
238
+ }
239
+ }
240
+ return out;
241
+ }
242
+ } else if (!/HTTP 404|Not Found/i.test(m.stderr)) {
243
+ return { ...emptyExisting(), status: 'failed' };
244
+ }
245
+
246
+ // No manifest (or unparseable): legacy directory listing.
196
247
  const r = spawnArgs('gh', ['api', `repos/${targetRepo}/contents/${contentPath}?ref=${branch}`, '--jq', '.[].name']);
197
248
  if (r.status === 0) {
198
- return { files: new Set(r.stdout.split('\n').filter(Boolean)), status: 'ok' };
249
+ return { ...emptyExisting(), files: new Set(r.stdout.split('\n').filter(Boolean)), status: 'ok' };
199
250
  }
200
251
  if (/HTTP 404|Not Found/i.test(r.stderr)) {
201
- return { files: new Set(), status: 'empty' };
252
+ return { ...emptyExisting(), status: 'empty' };
202
253
  }
203
- return { files: new Set(), status: 'failed' };
254
+ return { ...emptyExisting(), status: 'failed' };
204
255
  }
205
256
 
206
257
  // Full scan across the configured projects. `getExisting` is injectable for
@@ -219,9 +270,15 @@ export function scanAll(config, { projectKey = null, fetch = true, getExisting =
219
270
  }
220
271
 
221
272
  const results = projects.map((project) => {
222
- const existing = getExisting(config.targetRepo, branch, project.key, config.targetDir || '');
223
- const scanned = scanProject(project, { branch, fetch, existingFiles: existing.files });
273
+ // Normalized so an injected getExisting returning a partial shape (e.g. a
274
+ // legacy { files, status } double) can't crash the tombstone checks.
275
+ const existing = { ...emptyExisting(), status: 'ok', ...getExisting(config.targetRepo, branch, project.key, config.targetDir || '') };
276
+ const scanned = scanProject(project, { branch, fetch, existing });
224
277
  scanned.existenceCheck = existing.status;
278
+ // Live catalog rows for this project — the skill's topic-dedup input
279
+ // ("don't re-teach a guide the catalog already covers"), free with the
280
+ // manifest fetch above.
281
+ scanned.publishedEntries = existing.entries;
225
282
  return scanned;
226
283
  });
227
284
 
@@ -237,3 +294,25 @@ export function scanAll(config, { projectKey = null, fetch = true, getExisting =
237
294
  totalNewReleases: results.reduce((n, p) => n + p.newReleases.length, 0),
238
295
  };
239
296
  }
297
+
298
+ // Compact plan-table view of a scanAll result: per release, drop the commit
299
+ // list and diffstat (the bulky parts) for a commitCount; collapse skippedTags
300
+ // to per-reason counts. publishedEntries stays — it's small and the skill's
301
+ // topic-dedup input. Full detail remains one `scan --project <key>` away.
302
+ export function summarizeScan(result) {
303
+ if (result.error) return result;
304
+ return {
305
+ ...result,
306
+ projects: result.projects.map((p) => ({
307
+ ...p,
308
+ newReleases: p.newReleases.map(({ commits, diffstat, ...release }) => ({
309
+ ...release,
310
+ commitCount: commits.length,
311
+ })),
312
+ skippedTags: p.skippedTags.reduce((acc, { reason }) => {
313
+ acc[reason] = (acc[reason] || 0) + 1;
314
+ return acc;
315
+ }, {}),
316
+ })),
317
+ };
318
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natjswenson/devlog",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
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",
@@ -46,7 +46,9 @@
46
46
  },
47
47
  "scripts": {
48
48
  "test": "node --test \"tests/**/*.test.mjs\"",
49
- "audit": "npm audit --audit-level=moderate"
49
+ "audit": "npm audit --audit-level=moderate",
50
+ "prepack": "cp ../../README.md ../../LICENSE ../../CHANGELOG.md .",
51
+ "postpack": "rm -f README.md LICENSE CHANGELOG.md"
50
52
  },
51
53
  "dependencies": {
52
54
  "@vitejs/plugin-react": "6.0.1",
@@ -81,6 +81,26 @@
81
81
  "pattern": "this interactive review IS the quality gate for the cover",
82
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
83
  },
84
+ {
85
+ "id": "ground-truth-gate",
86
+ "pattern": "verify each one with a git command run NOW",
87
+ "rationale": "The 6-run audit found a post that published a provably false premise about the author's own repo (resume/v1.0.1: 'two of four packages never got tags' — all four existed). Every repo-fact claim must be re-verified against git in-session before publish; losing this line reopens the fabricated-premise path."
88
+ },
89
+ {
90
+ "id": "no-unrun-output",
91
+ "pattern": "never label output as real[\\s\\S]{0,120}unless the command that produced it ran in\\s+this session",
92
+ "rationale": "Two audited posts claimed 'real output' over commands that never ran (v0.6.0's phantom fixtures, v0.10.0's internally-impossible scan table). Output honesty must be a hard rule, not a stylistic preference."
93
+ },
94
+ {
95
+ "id": "tombstone-never-republish",
96
+ "pattern": "editorially retired[\\s\\S]{0,80}skip it silently, never regenerate",
97
+ "rationale": "Deleted/moved entries re-armed generation three times in the first six runs (ghostwriter v0.8.1 twice, market-research v0.1.0). The tombstoned state must always mean 'never regenerate'."
98
+ },
99
+ {
100
+ "id": "one-commit-one-changelog",
101
+ "pattern": "refuses a draft whose Changelog repeats a commit",
102
+ "rationale": "Monorepo twin releases shipped identical Changelogs (resume v1.0.1 + ghostwriter v0.8.1); a commit belongs to exactly one post's Changelog and publish-entry enforces it."
103
+ },
84
104
  {
85
105
  "id": "cover-custom-illustration",
86
106
  "pattern": "cover that just re-renders the title in large text is a failure",
@@ -95,5 +115,30 @@
95
115
  "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."
96
116
  }
97
117
  ],
98
- "cli_commands_referenced": ["scan", "lint-post", "publish-entry", "add-project", "remove-project", "set", "config", "init", "cover-context", "render-cover"]
118
+ "cli_commands_referenced": [
119
+ "scan",
120
+ "lint-post",
121
+ "publish-entry",
122
+ "add-project",
123
+ "remove-project",
124
+ "set",
125
+ "config",
126
+ "init",
127
+ "cover-context",
128
+ "render-cover",
129
+ "tombstone",
130
+ "sync-entry",
131
+ "assemble-post"
132
+ ],
133
+ "_baseline_comment": "Baseline eval sets: deterministic, offline, $0 checks pinned against artifacts from real local runs. These gate `ci / devlog` alongside the unit tests. Every entry names the test that enforces it so tools/lint_baseline.py can verify the declaration is not aspirational.",
134
+ "baseline": [
135
+ {
136
+ "id": "published-entries-still-lint-clean",
137
+ "kind": "corpus",
138
+ "test": "tests/baseline.test.mjs",
139
+ "corpus_glob": "evals/baseline/published/*.md",
140
+ "min_corpus": 8,
141
+ "rationale": "tests/lint_post.test.mjs unit-tests each rule against small crafted inputs and tests/evals.test.mjs drives the good/bad/irreproducible fixtures through the judge. All of those are hand-authored to exercise the rules; none answers whether the linter still accepts the real thing. evals/baseline/published/ holds eight entries copied verbatim from natejswenson.io — posts that went through the full pipeline, were reviewed, and are live — so 'this passes' means 'a human shipped this and stands behind it'. A rule that grows too strict starts rejecting work like this, and the cost lands at publish time on a real release. Deliberately a CURATED subset: of 61 published entries only 17 satisfy today's contract, the rest predating rules that landed later (the 5-10 tags-count range especially). Those are stale, not bad — asserting over all 61 would encode 'the linter must accept its own history', a different and wrong requirement. Paired negative assertions (bad-post.md must still produce findings; the three required-section rules must still fire) stop the corpus passing vacuously against a linter that returned nothing."
142
+ }
143
+ ]
99
144
  }