@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.
- package/SKILL.md +151 -82
- package/bin/devlog.js +160 -29
- package/examples/react/useDevLogEntries.js +2 -0
- package/image-style/icons.md +3 -2
- package/image-style/style-guide.example.md +165 -28
- package/lib/assemble_post.mjs +71 -0
- package/lib/config_ops.mjs +1 -0
- package/lib/core.mjs +12 -0
- package/lib/cover_gen.mjs +6 -1
- package/lib/lint_post.mjs +47 -1
- package/lib/migrate_entry_numbers.mjs +116 -0
- package/lib/publish_entry.mjs +255 -14
- package/lib/scan.mjs +101 -18
- package/package.json +1 -1
- package/skill-invariants.json +21 -1
|
@@ -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/config_ops.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/publish_entry.mjs
CHANGED
|
@@ -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 =
|
|
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.
|