@natjswenson/devlog 0.9.0 → 0.11.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.
@@ -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
+ }
@@ -31,6 +31,7 @@ export function removeProject(config, key) {
31
31
  const SETTERS = {
32
32
  targetRepo: (c, v) => ({ ...c, targetRepo: v }),
33
33
  branch: (c, v) => ({ ...c, branch: v }),
34
+ targetDir: (c, v) => (v === '' ? omit(c, 'targetDir') : { ...c, targetDir: v }),
34
35
  gitAuthor: (c, v) => ({ ...c, gitAuthor: v }),
35
36
  githubUser: (c, v) => ({ ...c, githubUser: v }),
36
37
  voicePath: (c, v) => (v === '' ? omit(c, 'voicePath') : { ...c, voicePath: expandHome(v) }),
package/lib/core.mjs CHANGED
@@ -113,6 +113,18 @@ export function validateConfig(config) {
113
113
  throw new Error(`branch must be a valid git branch name (no leading dash, no '..'): got ${JSON.stringify(config.branch)}`);
114
114
  }
115
115
  }
116
+ if ('targetDir' in config) {
117
+ // Optional: subdirectory of targetRepo holding the devlog content tree — e.g.
118
+ // `content/devlog` when the target is a site repo that renders the entries
119
+ // itself. Relative, slash-separated, no traversal, no leading/trailing slash.
120
+ // Interpolated into shell commands (the publish clone path) and the gh api
121
+ // contents path, so the charset is deliberately tight.
122
+ if (typeof config.targetDir !== 'string'
123
+ || !/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/.test(config.targetDir)
124
+ || config.targetDir.split('/').some((s) => s === '.' || s === '..')) {
125
+ throw new Error(`targetDir must be a relative path like "content/devlog" (no leading/trailing slash, no '..'): got ${JSON.stringify(config.targetDir)}`);
126
+ }
127
+ }
116
128
  if ('voicePath' in config) {
117
129
  // Optional: directory holding the voice profile used to write entries. Read by
118
130
  // the skill with the Read tool only — never shell-interpolated — so the only
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
  }
@@ -0,0 +1,116 @@
1
+ // One-time (but idempotent, safely re-runnable) migration that backfills the
2
+ // frozen `no` field onto every pre-existing manifest row that lacks one. Run
3
+ // once against the published corpus after `publishEntry` started emitting
4
+ // `no` for new entries (see publish_entry.mjs) — everything published before
5
+ // that point needs a number assigned retroactively.
6
+ //
7
+ // `no` is a single global sequence across ALL projects (issue numbers of one
8
+ // publication), so this walks every project directory under corpusDir
9
+ // together rather than numbering each project's manifest independently.
10
+ //
11
+ // Tiebreak for entries sharing a date (common in this corpus — multiple
12
+ // projects, and multiple releases of one project, often ship the same day):
13
+ // date ascending, then project name ascending, then filename ascending. Both
14
+ // of those are stable fields already on the row, so the order is
15
+ // deterministic and reproducible from the data alone, with no external
16
+ // input (e.g. "whichever I published first today") required to redo it.
17
+ //
18
+ // Mutates ONLY the `no` field. Never touches `.md` files. Never touches
19
+ // `cover`. Preserves every other field's value and the manifest's existing
20
+ // key order — `no` is inserted immediately before `cover` (matching where
21
+ // publishEntry places it on a fresh row) or appended at the end when the row
22
+ // has no cover, so old and newly-migrated rows end up shaped the same way.
23
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { atomicWriteJSON } from './core.mjs';
26
+
27
+ function compareRows(a, b) {
28
+ return String(a.date).localeCompare(String(b.date))
29
+ || a.project.localeCompare(b.project)
30
+ || String(a.file).localeCompare(String(b.file));
31
+ }
32
+
33
+ function insertNo(entry, no) {
34
+ const out = {};
35
+ let inserted = false;
36
+ for (const [key, value] of Object.entries(entry)) {
37
+ if (key === 'cover' && !inserted) {
38
+ out.no = no;
39
+ inserted = true;
40
+ }
41
+ out[key] = value;
42
+ }
43
+ if (!inserted) out.no = no;
44
+ return out;
45
+ }
46
+
47
+ // dryRun: compute and return the assignment without writing anything —
48
+ // useful to preview before committing to a run.
49
+ export function migrateEntryNumbers(corpusDir, { dryRun = false } = {}) {
50
+ if (!existsSync(corpusDir)) throw new Error(`Corpus directory not found: ${corpusDir}`);
51
+
52
+ const projects = readdirSync(corpusDir, { withFileTypes: true })
53
+ .filter((d) => d.isDirectory())
54
+ .map((d) => d.name)
55
+ .sort();
56
+
57
+ const manifestsByProject = new Map();
58
+ let maxNo = 0;
59
+ const unnumbered = [];
60
+
61
+ for (const project of projects) {
62
+ const manifestPath = join(corpusDir, project, 'manifest.json');
63
+ if (!existsSync(manifestPath)) continue;
64
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
65
+ if (!manifest || !Array.isArray(manifest.entries)) {
66
+ throw new Error(`Malformed manifest at ${manifestPath}: expected { "entries": [...] }.`);
67
+ }
68
+ manifestsByProject.set(project, manifest);
69
+
70
+ manifest.entries.forEach((entry, index) => {
71
+ if (!entry) return;
72
+ if (Number.isInteger(entry.no)) {
73
+ if (entry.no > maxNo) maxNo = entry.no;
74
+ } else {
75
+ unnumbered.push({ project, index, date: String(entry.date), file: String(entry.file) });
76
+ }
77
+ });
78
+ }
79
+
80
+ unnumbered.sort(compareRows);
81
+
82
+ const assigned = [];
83
+ let next = maxNo + 1;
84
+ for (const row of unnumbered) {
85
+ const manifest = manifestsByProject.get(row.project);
86
+ manifest.entries[row.index] = insertNo(manifest.entries[row.index], next);
87
+ assigned.push({ project: row.project, file: row.file, date: row.date, no: next });
88
+ next += 1;
89
+ }
90
+
91
+ const touchedProjects = [...new Set(unnumbered.map((r) => r.project))].sort();
92
+ if (!dryRun) {
93
+ for (const project of touchedProjects) {
94
+ atomicWriteJSON(join(corpusDir, project, 'manifest.json'), manifestsByProject.get(project));
95
+ }
96
+ }
97
+
98
+ return { assigned, touchedProjects, startingNo: maxNo + 1, endingNo: next - 1 };
99
+ }
100
+
101
+ // CLI entry point: `node migrate_entry_numbers.mjs <corpusDir> [--dry-run]`
102
+ if (import.meta.url === `file://${process.argv[1]}`) {
103
+ const corpusDir = process.argv[2];
104
+ const dryRun = process.argv.includes('--dry-run');
105
+ if (!corpusDir) {
106
+ console.error('Usage: node migrate_entry_numbers.mjs <corpusDir> [--dry-run]');
107
+ process.exit(2);
108
+ }
109
+ const result = migrateEntryNumbers(corpusDir, { dryRun });
110
+ console.log(JSON.stringify(result, null, 2));
111
+ if (result.assigned.length === 0) {
112
+ console.error('Nothing to do — every entry already has `no`.');
113
+ } else {
114
+ console.error(`${dryRun ? '[dry run] would assign' : 'Assigned'} ${result.assigned.length} numbers (${result.startingNo}..${result.endingNo}).`);
115
+ }
116
+ }
@@ -2,10 +2,10 @@
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, writeFileSync, copyFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
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,118 @@ 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
+
133
+ // `no` is a single sequence across ALL projects (issue numbers of one
134
+ // publication, not per-project counters), but manifests are stored one per
135
+ // project — so "next" means "scan every project's manifest under cloneDir and
136
+ // take the highest `no` seen, plus one." Pre-migration rows with no `no` field
137
+ // are simply skipped, not treated as 0; a manifest a sibling agent is mid-write
138
+ // on is skipped rather than thrown on, since a transient parse failure on
139
+ // ANOTHER project must never block publishing to THIS one.
140
+ // NOT safe against two publishEntry calls racing in separate processes at the
141
+ // same instant (read-then-write with no lock) — acceptable for this single-
142
+ // operator CLI; a real lock is not worth the complexity until that changes.
143
+ function nextEntryNumber(cloneDir) {
144
+ let dirents;
145
+ try {
146
+ dirents = readdirSync(cloneDir, { withFileTypes: true });
147
+ } catch {
148
+ return 1;
149
+ }
150
+
151
+ let max = 0;
152
+ for (const dirent of dirents) {
153
+ if (!dirent.isDirectory()) continue;
154
+ const manifestPath = join(cloneDir, dirent.name, 'manifest.json');
155
+ if (!existsSync(manifestPath)) continue;
156
+ let manifest;
157
+ try {
158
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
159
+ } catch {
160
+ continue;
161
+ }
162
+ if (!manifest || !Array.isArray(manifest.entries)) continue;
163
+ for (const entry of manifest.entries) {
164
+ if (entry && Number.isInteger(entry.no) && entry.no > max) max = entry.no;
165
+ }
166
+ }
167
+ return max + 1;
168
+ }
169
+
58
170
  export function publishEntry({ cloneDir, project, version, entryPath, coverImageBuffer }) {
59
171
  if (!RE_PROJECT_KEY.test(project) || project.includes('..')) {
60
172
  throw new Error(`Invalid project key: ${JSON.stringify(project)}`);
@@ -67,16 +179,33 @@ export function publishEntry({ cloneDir, project, version, entryPath, coverImage
67
179
 
68
180
  const projectDir = join(cloneDir, project);
69
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
+ }
70
197
  if (existsSync(destPath)) {
71
198
  throw new Error(`Entry ${project}/${version}.md already exists — a cut release is immutable, refusing to overwrite.`);
72
199
  }
73
200
 
74
201
  const content = readFileSync(entryPath, 'utf8');
75
- const { data } = parseFrontmatter(content);
202
+ const { data, body } = parseFrontmatter(content);
76
203
  if (!data || !data.title || !data.date || !data.summary) {
77
204
  throw new Error('Entry frontmatter must include title, date, and summary (run lint-post first).');
78
205
  }
79
206
 
207
+ assertNoChangelogCollision(cloneDir, project, version, body);
208
+
80
209
  mkdirSync(projectDir, { recursive: true });
81
210
  copyFileSync(entryPath, destPath);
82
211
 
@@ -91,21 +220,18 @@ export function publishEntry({ cloneDir, project, version, entryPath, coverImage
91
220
  writeFileSync(join(projectDir, coverFile), coverImageBuffer);
92
221
  }
93
222
 
94
- const manifestPath = join(projectDir, 'manifest.json');
95
- let manifest = { entries: [] };
96
- if (existsSync(manifestPath)) {
97
- manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
98
- if (!manifest || !Array.isArray(manifest.entries)) {
99
- throw new Error(`Malformed manifest at ${manifestPath}: expected { "entries": [...] }.`);
100
- }
101
- }
102
-
103
223
  const file = `${version}.md`;
104
224
  // Idempotent: legacy manifests may already reference this file/version even
105
225
  // when the .md was missing — never duplicate an index row.
106
226
  const already = manifest.entries.some((e) => e && (e.file === file || e.version === version));
107
227
  let manifestUpdated = false;
228
+ // Frozen at publish, never recomputed: a backdated entry published later must
229
+ // never shift a number already baked into a live published social image.
230
+ // Computed only on the write path — a repeat/idempotent call that hits
231
+ // `already` above must not burn a number on a publish that's a no-op.
232
+ let no = null;
108
233
  if (!already) {
234
+ no = nextEntryNumber(cloneDir);
109
235
  manifest.entries.push({
110
236
  date: String(data.date),
111
237
  file,
@@ -113,14 +239,129 @@ export function publishEntry({ cloneDir, project, version, entryPath, coverImage
113
239
  summary: String(data.summary),
114
240
  version,
115
241
  tags: Array.isArray(data.tags) ? data.tags : [],
242
+ no,
116
243
  ...(coverFile ? { cover: { file: coverFile, bytes: coverImageBuffer.length } } : {}),
117
244
  });
118
- manifest.entries = sortEntries(manifest.entries);
245
+ manifest.entries = sortManifestEntries(manifest.entries);
119
246
  atomicWriteJSON(manifestPath, manifest);
120
247
  manifestUpdated = true;
121
248
  }
122
249
 
123
- return { written: destPath, manifestUpdated, coverWritten: !!coverFile };
250
+ return { written: destPath, manifestUpdated, coverWritten: !!coverFile, no };
251
+ }
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 };
124
365
  }
125
366
 
126
367
  // Backfill path only: add a cover to an entry that was already published without one.