@natjswenson/devlog 0.10.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 CHANGED
@@ -16,7 +16,7 @@ actual work, backed by cited reputable sources, and including **gotchas earned f
16
16
  experience**. The release is the springboard; the teaching is the point.
17
17
 
18
18
  The deterministic work (release discovery, post linting, manifest updates, config edits)
19
- is done by the `@natjswenson/devlog` CLI — invoke it as `npx -y @natjswenson/devlog <cmd>`.
19
+ is done by the `@natjswenson/devlog` CLI — invoke it as `npx -y @natjswenson/devlog@latest <cmd>`.
20
20
  Every agent-facing command prints JSON.
21
21
 
22
22
  ## Decide which mode you're in
@@ -38,10 +38,10 @@ Map the user's request onto the CLI — never hand-edit `config.json`:
38
38
 
39
39
  | Intent | Command |
40
40
  |---|---|
41
- | Show config | `npx -y @natjswenson/devlog config --json` |
42
- | Add a project | `npx -y @natjswenson/devlog add-project --yes --path <abs-path> [--key K] [--remote O/R] [--label L] [--tag-prefix P] [--path-filter F] [--private]` |
43
- | Remove a project | `npx -y @natjswenson/devlog remove-project <key> --yes` |
44
- | Change a setting | `npx -y @natjswenson/devlog set <field> <value>` (settable: `targetRepo`, `branch`, `targetDir`, `gitAuthor`, `githubUser`, `voicePath`, `deepDive.minSources`, `deepDive.topicDomains`) |
41
+ | Show config | `npx -y @natjswenson/devlog@latest config --json` |
42
+ | Add a project | `npx -y @natjswenson/devlog@latest add-project --yes --path <abs-path> [--key K] [--remote O/R] [--label L] [--tag-prefix P] [--path-filter F] [--private]` |
43
+ | Remove a project | `npx -y @natjswenson/devlog@latest remove-project <key> --yes` |
44
+ | Change a setting | `npx -y @natjswenson/devlog@latest set <field> <value>` (settable: `targetRepo`, `branch`, `targetDir`, `gitAuthor`, `githubUser`, `voicePath`, `deepDive.minSources`, `deepDive.topicDomains`) |
45
45
 
46
46
  `targetDir` is the subdirectory of `targetRepo` that holds the devlog content tree
47
47
  (e.g. `content/devlog` when the target is the site repo itself); unset/empty means the
@@ -71,26 +71,32 @@ fix the named field via `set`.
71
71
 
72
72
  ## Status mode
73
73
 
74
- Run `npx -y @natjswenson/devlog scan --json` and render a compact table: project, new
75
- releases (version + date + commit count), and skipped tags worth mentioning (reason
76
- `prerelease`, `empty-range`, etc. — omit `entry-exists` noise unless asked). Note
77
- `tagFetch: "failed"` ("using local tags only") and `existenceCheck: "failed"` ("couldn't
78
- confirm which entries exist; publish will still refuse overwrites"). Write nothing.
74
+ Run `npx -y @natjswenson/devlog@latest scan --json --summary` and render a compact table:
75
+ project, new releases (version + date + `commitCount`), and skipped-tag counts worth
76
+ mentioning (`prerelease`, `empty-range`, `entry-tombstoned`, etc. — omit `entry-exists`
77
+ noise unless asked). Note `tagFetch: "failed"` ("using local tags only") and
78
+ `existenceCheck: "failed"` ("couldn't confirm which entries exist; publish will still
79
+ refuse overwrites"). Write nothing.
79
80
 
80
81
  ## Generate mode
81
82
 
82
83
  ### Step 1: Scan for new releases
83
84
 
84
85
  ```bash
85
- npx -y @natjswenson/devlog scan --json # all projects
86
- npx -y @natjswenson/devlog scan --json --project '<key>' # one project
86
+ npx -y @natjswenson/devlog@latest scan --json --summary # the plan table
87
+ npx -y @natjswenson/devlog@latest scan --json --project '<key>' # full detail, one project
87
88
  ```
88
89
 
89
- The JSON plan contains everything discovery used to require: per project, the
90
- `newReleases` array (`tag`, `version`, `date`, `prevTag`, `commits[{hash, subject, date,
91
- public}]`, `diffstat`) plus `skippedTags` with reasons, and the resolved `deepDive`
92
- settings (`minSources`, `topicDomains`). Handle the edges:
90
+ Start with `--summary`: per project it gives `newReleases` (`tag`, `version`, `date`,
91
+ `commitCount`), per-reason `skippedTags` counts, `publishedEntries` (every live entry's
92
+ `version`/`title`/`tags` the topic-dedup input for 3b), and the resolved `deepDive`
93
+ settings (`minSources`, `topicDomains`). When you start writing a project's posts, run
94
+ the full per-project scan for its `commits[{hash, subject, date, public}]` and
95
+ `diffstat`. Handle the edges:
93
96
 
97
+ - The output echoes `cliVersion` — the version npx actually ran. npx caches aggressively;
98
+ if it's older than the version this SKILL.md shipped with, say so and re-run (the
99
+ `@latest` pin usually prevents this).
94
100
  - `error: "unknown-project"` → list `availableKeys` and stop.
95
101
  - `totalNewReleases: 0` → tell the user nothing new was tagged (mention notable skipped
96
102
  tags) and stop. Do not create empty entries.
@@ -150,14 +156,28 @@ trivia or niche internals. If the obvious topic is repo-specific, step up one le
150
156
  the general pattern behind it — the test is "could a reader finish this how-to and have
151
157
  something working of their own?" When a release spans two candidate topics (or two
152
158
  releases in one run share one), don't write the same guide twice: give each post the
153
- most usable topic the run hasn't already covered. In a monorepo, one commit can appear
154
- in several projects' ranges it belongs to the post whose release story it is; other
155
- posts leave it out of their narrative and `## Changelog` (check what sibling entries
156
- already cover, across all projects).
159
+ most usable topic the run hasn't already covered. **The same rule applies against the
160
+ whole catalog**: before settling on a topic, check every project's `publishedEntries`
161
+ (titles + tags, in the scan output) if an existing entry already teaches this topic,
162
+ find the angle this release genuinely adds, or step to the next-most-usable topic.
163
+ Never publish a near-duplicate of a guide the catalog already has. Vary the surface
164
+ too: don't reuse the catalog's title shapes ("How to …" again and again) or repeat the
165
+ same section-heading skeleton post after post.
166
+
167
+ In a monorepo, one commit can appear in several projects' ranges — it belongs to the
168
+ post whose release story it is; other posts leave it out of their narrative and
169
+ `## Changelog` (`publish-entry` refuses a draft whose Changelog repeats a commit an
170
+ existing entry already lists). **Never force a second angle the history doesn't
171
+ support**: if the twin release has no story of its own beyond the shared commits, give
172
+ it a proportionally small post — or fold it into the sibling's `## Shipped` — rather
173
+ than inventing a premise to differentiate it.
157
174
 
158
175
  **3c. Research before writing.** Use web search/fetch to gather at least
159
176
  `deepDive.minSources` **distinct** reputable sources: official docs and release notes,
160
- standards bodies, primary research, well-regarded engineering writing. Avoid SEO farms.
177
+ standards bodies, primary research, well-regarded engineering writing. A content farm
178
+ or SEO-mill page never counts toward `minSources` — when a concept has a primary source
179
+ (the original paper, the official docs, the pattern's canonical text), cite that, not a
180
+ summary site that ranks for the keyword.
161
181
  Every specific external claim (a version, a behavior, a study, a definition) must be
162
182
  backed by a source you actually verified — if you can't source it, don't claim it. Don't
163
183
  lean on one URL for most claims. Keep a working `(claim, url)` list. Fetch tools can
@@ -277,16 +297,28 @@ Write each draft with the **Write tool** (never a bash heredoc) to a temp dir
277
297
  (`mktemp -d` once, reuse the absolute path — shell variables don't persist across bash
278
298
  calls). Name it `<version>.md`. Then:
279
299
 
280
- 1. **Lint:** `npx -y @natjswenson/devlog lint-post '<abs-draft-path>'` fix every
281
- finding (missing sections, thin gotchas, too few distinct sources, sources listed
282
- but never cited inline, untagged fences).
283
- 2. **Assemble-and-run check:** when the post's code is runnable without external
284
- services, copy its code blocks in order into a scratch dir and execute them exactly
285
- as a reader would. Anything undefined, out of order, or missing an entrypoint fails
286
- the stranger test mechanically fix the post, not just the scratch copy.
287
- 3. **Self-review against the how-to contract**, honestly, as a skeptical reader: walk
300
+ 1. **Ground-truth gate.** List every claim the draft makes about the user's own repo
301
+ a tag exists or doesn't, a count ("seven tests went red"), a timeline, an outcome —
302
+ and verify each one with a git command run NOW, in this session (`git tag -l`,
303
+ `git show`, `git log`), the same way 3c keeps a `(claim, url)` list for external
304
+ claims. A claim you can't verify gets removed, not softened. A specific number is
305
+ publishable only if it appears in a commit, a diff, or a fetched source — otherwise
306
+ drop the precision ("several", not "seven"). And never label output as real ("that's
307
+ the real output", "from my actual run") unless the command that produced it ran in
308
+ this session — unrun output is always framed as expectation ("you should see…").
309
+ 2. **Lint:** `npx -y @natjswenson/devlog@latest lint-post '<abs-draft-path>' --voice` —
310
+ fix every finding (missing sections, thin gotchas, too few distinct sources, sources
311
+ listed but never cited inline, untagged fences, voice violations).
312
+ 3. **Assemble-and-run check:**
313
+ `npx -y @natjswenson/devlog@latest assemble-post '<abs-draft-path>' --out '<scratch>/assemble/<version>'`
314
+ extracts the draft's code blocks in order as numbered files (`text` fences are
315
+ expected output, listed but not written). When the code is runnable without external
316
+ services, execute the blocks exactly as a reader would. Anything undefined, out of
317
+ order, or missing an entrypoint (or an install step the post never shows) fails the
318
+ stranger test mechanically — fix the post, not just the scratch copy.
319
+ 4. **Self-review against the how-to contract**, honestly, as a skeptical reader: walk
288
320
  points 1-8 above plus voice adherence. Revise the draft for any point that fails.
289
- 4. At most **two** revision passes; then proceed with the best version and carry any
321
+ 5. At most **two** revision passes; then proceed with the best version and carry any
290
322
  residual weakness into the final summary (e.g. "v0.5.0: only 2 gotchas had commit
291
323
  evidence").
292
324
 
@@ -312,57 +344,66 @@ mktemp -d # → record the absolute path, e.g. /var/folders/.../tmp.abc
312
344
  git -C '<abs-tmp>' clone --depth=1 'https://github.com/<targetRepo>.git'
313
345
  # <content-root> = '<abs-tmp>/<repo-name>/<targetDir>' if targetDir is set,
314
346
  # '<abs-tmp>/<repo-name>' otherwise.
347
+ ```
315
348
 
316
- # Per release (refuses to overwrite an existing entry on {"error": ...,
317
- # "message": "... immutable ..."} skip that release and note it):
318
-
319
- # 1. Style guide + up to 3 reference images of recently published covers.
320
- npx -y @natjswenson/devlog cover-context '<key>' '<version>' \
321
- --clone '<content-root>'
322
- # On {"error": "style-guide-missing", ...}: skip cover composition for this release
323
- # entirely — proceed straight to publish-entry with no --cover flag. Never block
324
- # publish on a missing style guide.
325
-
326
- # 2. Compose the cover using ONLY this release's title/tags/summary/`## Shipped` text
327
- # (never the raw draft file, never `## Changelog`) plus the returned style guide,
328
- # icon catalog, and reference images. A cover that just re-renders the title in large text is a failure —
329
- # find the one concrete mechanism this release is actually about
330
- # (not the project name, not "a bug fix") and draw ONE custom inline-SVG illustration
331
- # of it, sized as the dominant visual element of the canvas; title/kicker stay
332
- # secondary. Two different releases should never produce visually similar covers.
333
- #
334
- # Draw the illustration inside a `#hero-zone` container at exactly
335
- # `x:150 y:425 width:1300 height:400` (render-cover mechanically checks this box and
336
- # refuses to render otherwise) — pick ONE of two composition slots per post: single
337
- # centered hero (one freehand mechanism, nothing else), or two-node before/after (a
338
- # left node, a right node, a connecting line, all freehand). Snap interior key points
339
- # to a 25px coordinate grid. Catalog icons (image-style/icons.md) are never placed
340
- # inside `#hero-zone` they may only appear as an optional small accent glyph near
341
- # the kicker/title area, entirely outside the hero zone, its bottom edge no lower than
342
- # y:400. See the style guide's hero-zone grid contract section before composing.
343
- # Write the result with the Write tool to '<abs-scratch>/<key>/<version>.html' — a
344
- # full document starting with `<!DOCTYPE html>`, sized
345
- # `html, body { margin:0; width:1600px; height:900px; }`, referencing the bundled
346
- # font only as `font-family: 'DevlogCoverFont', sans-serif`.
347
-
348
- # 3. Rasterize it. On failure (render timeout / Chromium not installed / font missing /
349
- # a #hero-zone problem missing, duplicate, wrong position/size, or a catalog icon
350
- # overlapping it),
351
- # the .html is left in place for debugging retry composing once with the error text
352
- # fed back, or give up and proceed with no --cover flag.
353
- npx -y @natjswenson/devlog render-cover '<abs-scratch>/<key>/<version>.html' \
354
- --project '<key>' --slug '<version>' --out '<abs-scratch>'
355
- # Show the rendered <abs-scratch>/<key>/<version>.png in this session before continuing
356
- # this interactive review IS the quality gate for the cover, the same way Step 4 is for
357
- # the prose.
358
-
359
- npx -y @natjswenson/devlog publish-entry \
360
- --clone '<content-root>' --project '<key>' \
361
- --version '<version>' --entry '<abs-draft-path>' \
362
- --cover '<abs-scratch>/<key>/<version>.png'
363
- # Omit --cover entirely if no cover was produced for this release (missing style guide,
364
- # a render failure not worth a second attempt) publish still proceeds normally.
349
+ Right after the clone, **Write `<abs-tmp>/run-state.json`**: the clone path, the planned
350
+ releases, and a per-release status you update as each one drafts/lints/publishes. If the
351
+ session is compacted or interrupted mid-run, re-read it instead of re-deriving paths
352
+ from `/var/folders` archaeology.
353
+
354
+ Per release:
355
+
356
+ 1. **Cover context.**
357
+ `npx -y @natjswenson/devlog@latest cover-context '<key>' '<version>' --clone '<content-root>'`
358
+ returns the style guide, icon catalog, and up to 3 reference cover paths. **Read
359
+ only the single most recent reference image** (image reads are the expensive part;
360
+ open another only if you're genuinely unsure the new cover is distinct). On
361
+ `{"error": "style-guide-missing"}`: skip cover composition for this release entirely
362
+ and proceed straight to publish-entry with no `--cover` flag.
363
+ Never block publish on a missing style guide.
364
+ 2. **Compose** using ONLY this release's title/tags/summary/`## Shipped` text
365
+ (never the raw draft file, never `## Changelog`) plus the style guide and icon catalog. A
366
+ cover that just re-renders the title in large text is a failure — find the one
367
+ concrete mechanism this release is actually about (not the project name, not "a bug
368
+ fix") and draw ONE custom inline-SVG illustration of it as the dominant visual
369
+ element; title/kicker stay secondary; two releases should never produce visually
370
+ similar covers. Follow the style guide's hero-zone grid contract: the illustration
371
+ lives in a `#hero-zone` container at exactly `x:150 y:425 width:1300 height:400`
372
+ (render-cover mechanically enforces this), one of two slots (single centered hero,
373
+ or two-node before/after), interior points snapped to a 25px grid; catalog icons
374
+ never go inside `#hero-zone` (optional small accent glyph near the kicker only,
375
+ bottom edge above y:400). Write the document with the Write tool to
376
+ `'<abs-scratch>/<key>/<version>.html'`full `<!DOCTYPE html>` document, sized
377
+ `html, body { margin:0; width:1600px; height:900px; }`, font referenced only as
378
+ `font-family: 'DevlogCoverFont', sans-serif`.
379
+ 3. **Rasterize.**
380
+ `npx -y @natjswenson/devlog@latest render-cover '<abs-scratch>/<key>/<version>.html' --project '<key>' --slug '<version>' --out '<abs-scratch>'`
381
+ The HTML is the source of truth and **survives the render**: to fix a visual
382
+ problem, edit (or re-Writeif an Edit fails, re-Write the whole file) the same
383
+ .html and re-run render-cover; it re-renders whenever the HTML is present,
384
+ overwriting the old PNG. On `render-failed` (timeout / Chromium missing / font
385
+ missing / a `#hero-zone` geometry violation), retry composing once with the error
386
+ text fed back, or give up and proceed with no `--cover` flag. **Show the rendered
387
+ `<abs-scratch>/<key>/<version>.png` in this session before continuing —
388
+ this interactive review IS the quality gate for the cover**, the same way Step 4
389
+ is for the prose.
390
+ 4. **Publish.**
391
+ ```bash
392
+ npx -y @natjswenson/devlog@latest publish-entry \
393
+ --clone '<content-root>' --project '<key>' \
394
+ --version '<version>' --entry '<abs-draft-path>' \
395
+ --cover '<abs-scratch>/<key>/<version>.png'
396
+ ```
397
+ Omit `--cover` entirely if no cover was produced (missing style guide, a render
398
+ failure not worth a second attempt) — publish still proceeds normally. publish-entry
399
+ refuses three things; none is retried by workaround: an existing entry (`immutable`
400
+ — skip the release, note it), a tombstoned version (`tombstoned` — skip; that
401
+ identity was editorially retired), and a Changelog commit another entry already
402
+ lists (drop the commit from this draft's `## Changelog` and re-publish).
403
+
404
+ Then, once, after all releases:
365
405
 
406
+ ```bash
366
407
  git -C '<abs-tmp>/<repo-name>' add .
367
408
  git -C '<abs-tmp>/<repo-name>' commit -m 'devlog: add release entries'
368
409
  git -C '<abs-tmp>/<repo-name>' push --no-tags origin '<branch>'
@@ -415,5 +456,17 @@ rules:
415
456
  not an error.
416
457
  - **Entry already exists** (skipped as `entry-exists`, or `publish-entry` refuses): a cut
417
458
  release is immutable; never overwrite, never delete.
459
+ - **`entry-tombstoned`:** the release's entry was editorially retired (moved,
460
+ consolidated, or deleted on purpose) — skip it silently, never regenerate it.
461
+ - **The user moved/consolidated/deleted a published entry by hand:** tombstone the
462
+ identity it left behind so no later run resurrects it:
463
+ `npx -y @natjswenson/devlog@latest tombstone --clone '<content-root>' --project '<key>'
464
+ --version '<vX.Y.Z>' --reason '<where it went>'` (commit + push like a publish).
465
+ - **The user edited a published entry's prose/frontmatter:** resync its manifest row
466
+ (title/summary/date/tags — what the site index and RSS read) with
467
+ `npx -y @natjswenson/devlog@latest sync-entry --clone '<content-root>' --project '<key>'
468
+ --slug '<version>'`. If the result reports `coverStale: true` and the title/topic
469
+ changed, offer to recompose the cover (Step 5 flow, `--cover` via publish is not
470
+ needed — use the backfill commands or recompose+`render-cover`+commit).
418
471
  - **Unknown project argument:** list the available keys from the scan error.
419
472
  - **Config missing/invalid:** point at `npx @natjswenson/devlog init` / `set` and stop.
package/bin/devlog.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { spawn, spawnSync, execSync } from 'node:child_process';
3
3
  import {
4
4
  existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, realpathSync,
5
- readdirSync, statSync, unlinkSync, rmSync, mkdtempSync,
5
+ readdirSync, statSync, rmSync, mkdtempSync,
6
6
  } from 'node:fs';
7
7
  import { dirname, join, resolve, basename } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
@@ -32,9 +32,10 @@ import {
32
32
  validateConfig,
33
33
  resolveDeepDive,
34
34
  } from '../lib/core.mjs';
35
- import { scanAll } from '../lib/scan.mjs';
35
+ import { scanAll, summarizeScan } from '../lib/scan.mjs';
36
36
  import { lintPost, parseFrontmatter, splitSections } from '../lib/lint_post.mjs';
37
- import { publishEntry, addCoverToExistingEntry } from '../lib/publish_entry.mjs';
37
+ import { publishEntry, addCoverToExistingEntry, tombstoneEntry, syncEntryFromFrontmatter } from '../lib/publish_entry.mjs';
38
+ import { writeAssembledBlocks } from '../lib/assemble_post.mjs';
38
39
  import { addProject, removeProject, setField, SETTABLE_FIELDS } from '../lib/config_ops.mjs';
39
40
  import { loadStyleGuide, getRecentCovers, mergeManifestEntries } from '../lib/cover_gen.mjs';
40
41
  import { renderCoverImage } from '../lib/render_cover.mjs';
@@ -110,6 +111,18 @@ function emitJSON(obj, exitCode = 0) {
110
111
  process.exit(exitCode);
111
112
  }
112
113
 
114
+ // parseArgs is strict by default, so an unknown flag (`render-cover --force`)
115
+ // used to die with a raw ERR_PARSE_ARGS_UNKNOWN_OPTION stack trace instead of
116
+ // the JSON error shape every agent-facing command promises. Same contract as
117
+ // commit-covers' hand-rolled parser: unknown/malformed flags → bad-flag JSON.
118
+ function safeParseArgs(spec) {
119
+ try {
120
+ return parseArgs(spec);
121
+ } catch (e) {
122
+ emitJSON({ error: 'bad-flag', message: e.message }, 2);
123
+ }
124
+ }
125
+
113
126
  function readValidConfigOrExit({ json = false } = {}) {
114
127
  if (!existsSync(CONFIG_PATH)) {
115
128
  if (json) emitJSON({ error: 'config-missing', path: CONFIG_PATH, hint: 'Run `npx @natjswenson/devlog init` first.' }, 1);
@@ -462,7 +475,7 @@ async function cmdInit() {
462
475
 
463
476
  // ─── add-project ─────────────────────────────────────────────────────────────
464
477
  async function cmdAddProject(rest) {
465
- const { values } = parseArgs({
478
+ const { values } = safeParseArgs({
466
479
  args: rest,
467
480
  options: {
468
481
  path: { type: 'string' },
@@ -541,7 +554,7 @@ async function cmdAddProject(rest) {
541
554
 
542
555
  // ─── remove-project ──────────────────────────────────────────────────────────
543
556
  async function cmdRemoveProject(rest) {
544
- const { values, positionals } = parseArgs({
557
+ const { values, positionals } = safeParseArgs({
545
558
  args: rest,
546
559
  options: { yes: { type: 'boolean', default: false } },
547
560
  allowPositionals: true,
@@ -571,7 +584,7 @@ async function cmdRemoveProject(rest) {
571
584
 
572
585
  // ─── set ─────────────────────────────────────────────────────────────────────
573
586
  function cmdSet(rest) {
574
- const { positionals } = parseArgs({ args: rest, options: {}, allowPositionals: true });
587
+ const { positionals } = safeParseArgs({ args: rest, options: {}, allowPositionals: true });
575
588
  const [field, value] = positionals;
576
589
  const config = readValidConfigOrExit({ json: true });
577
590
  if (!field || value === undefined) {
@@ -588,11 +601,14 @@ function cmdSet(rest) {
588
601
 
589
602
  // ─── scan ────────────────────────────────────────────────────────────────────
590
603
  function cmdScan(rest) {
591
- const { values } = parseArgs({
604
+ const { values } = safeParseArgs({
592
605
  args: rest,
593
606
  options: {
594
607
  project: { type: 'string' },
595
608
  'no-fetch': { type: 'boolean', default: false },
609
+ // Plan-table view: per release, commitCount instead of the commit list
610
+ // and diffstat; skippedTags collapsed to per-reason counts.
611
+ summary: { type: 'boolean', default: false },
596
612
  // scan always emits JSON; the flag is accepted so `scan --json` (as
597
613
  // SKILL.md spells it) is never a crash.
598
614
  json: { type: 'boolean', default: true },
@@ -600,19 +616,29 @@ function cmdScan(rest) {
600
616
  allowPositionals: false,
601
617
  });
602
618
  const config = readValidConfigOrExit({ json: true });
603
- const result = scanAll(config, { projectKey: values.project || null, fetch: !values['no-fetch'] });
619
+ let result = scanAll(config, { projectKey: values.project || null, fetch: !values['no-fetch'] });
620
+ if (values.summary) result = summarizeScan(result);
621
+ // Which CLI actually ran: npx caches aggressively, and a stale install has
622
+ // silently missed shipped fixes before — the skill compares this against the
623
+ // version its own instructions shipped with.
624
+ if (!result.error) result.cliVersion = readPackageVersion();
604
625
  emitJSON(result, result.error ? 1 : 0);
605
626
  }
606
627
 
607
628
  // ─── lint-post ───────────────────────────────────────────────────────────────
608
629
  function cmdLintPost(rest) {
609
- const { values, positionals } = parseArgs({
630
+ const { values, positionals } = safeParseArgs({
610
631
  args: rest,
611
- options: { 'min-sources': { type: 'string' } },
632
+ options: {
633
+ 'min-sources': { type: 'string' },
634
+ // Deterministic voice-contract rules (em dashes, banned phrases) —
635
+ // opt-in so non-voice callers and the eval harness keep their behavior.
636
+ voice: { type: 'boolean', default: false },
637
+ },
612
638
  allowPositionals: true,
613
639
  });
614
640
  const file = positionals[0];
615
- if (!file) emitJSON({ error: 'missing-arg', message: 'Usage: devlog lint-post <file> [--min-sources N]' }, 2);
641
+ if (!file) emitJSON({ error: 'missing-arg', message: 'Usage: devlog lint-post <file> [--min-sources N] [--voice]' }, 2);
616
642
 
617
643
  let minSources;
618
644
  if (values['min-sources'] !== undefined) {
@@ -633,13 +659,13 @@ function cmdLintPost(rest) {
633
659
  } catch (e) {
634
660
  emitJSON({ error: 'unreadable', message: e.message }, 2);
635
661
  }
636
- const result = lintPost(content, { minSources, filename: file });
662
+ const result = lintPost(content, { minSources, filename: file, voice: values.voice });
637
663
  emitJSON({ ...result, minSources }, result.ok ? 0 : 1);
638
664
  }
639
665
 
640
666
  // ─── publish-entry ───────────────────────────────────────────────────────────
641
667
  function cmdPublishEntry(rest) {
642
- const { values } = parseArgs({
668
+ const { values } = safeParseArgs({
643
669
  args: rest,
644
670
  options: {
645
671
  clone: { type: 'string' },
@@ -677,6 +703,92 @@ function cmdPublishEntry(rest) {
677
703
  }
678
704
  }
679
705
 
706
+ // ─── tombstone ───────────────────────────────────────────────────────────────
707
+ // Editorially retire a (project, version) identity after its entry was moved,
708
+ // consolidated, or deleted by hand — scan then reports `entry-tombstoned` and
709
+ // publish-entry refuses it forever.
710
+ function cmdTombstone(rest) {
711
+ const { values } = safeParseArgs({
712
+ args: rest,
713
+ options: {
714
+ clone: { type: 'string' },
715
+ project: { type: 'string' },
716
+ version: { type: 'string' },
717
+ reason: { type: 'string' },
718
+ },
719
+ allowPositionals: false,
720
+ });
721
+ for (const flag of ['clone', 'project', 'version', 'reason']) {
722
+ if (!values[flag]) emitJSON({ error: 'missing-flag', message: `tombstone requires --${flag}` }, 1);
723
+ }
724
+ try {
725
+ const result = tombstoneEntry({
726
+ cloneDir: expandHome(values.clone),
727
+ project: values.project,
728
+ version: values.version,
729
+ reason: values.reason,
730
+ });
731
+ emitJSON({ ok: true, ...result });
732
+ } catch (e) {
733
+ emitJSON({ error: 'tombstone-failed', message: e.message }, 1);
734
+ }
735
+ }
736
+
737
+ // ─── sync-entry ──────────────────────────────────────────────────────────────
738
+ // Resync a published entry's manifest row (title/summary/date/tags) from its
739
+ // .md frontmatter after a deliberate post-publish edit.
740
+ function cmdSyncEntry(rest) {
741
+ const { values } = safeParseArgs({
742
+ args: rest,
743
+ options: {
744
+ clone: { type: 'string' },
745
+ project: { type: 'string' },
746
+ slug: { type: 'string' },
747
+ },
748
+ allowPositionals: false,
749
+ });
750
+ for (const flag of ['clone', 'project', 'slug']) {
751
+ if (!values[flag]) emitJSON({ error: 'missing-flag', message: `sync-entry requires --${flag}` }, 1);
752
+ }
753
+ try {
754
+ const result = syncEntryFromFrontmatter({
755
+ cloneDir: expandHome(values.clone),
756
+ project: values.project,
757
+ slug: values.slug,
758
+ });
759
+ emitJSON({ ok: true, ...result });
760
+ } catch (e) {
761
+ emitJSON({ error: 'sync-failed', message: e.message }, 1);
762
+ }
763
+ }
764
+
765
+ // ─── assemble-post ───────────────────────────────────────────────────────────
766
+ // Extract a draft's fenced code blocks, in order, into numbered files so the
767
+ // Step 4 assemble-and-run check is mechanical instead of hand-copied.
768
+ function cmdAssemblePost(rest) {
769
+ const { values, positionals } = safeParseArgs({
770
+ args: rest,
771
+ options: { out: { type: 'string' } },
772
+ allowPositionals: true,
773
+ });
774
+ const file = positionals[0];
775
+ if (!file) emitJSON({ error: 'missing-arg', message: 'Usage: devlog assemble-post <draft> --out <dir>' }, 2);
776
+ if (!values.out) emitJSON({ error: 'missing-flag', message: 'assemble-post requires --out' }, 1);
777
+
778
+ let content;
779
+ try {
780
+ content = readFileSync(expandHome(file), 'utf8');
781
+ } catch (e) {
782
+ emitJSON({ error: 'unreadable', message: e.message }, 2);
783
+ }
784
+ try {
785
+ const result = writeAssembledBlocks(content, expandHome(values.out));
786
+ emitJSON({ ok: true, ...result });
787
+ } catch (e) {
788
+ emitJSON({ error: 'assemble-failed', message: e.message }, 1);
789
+ }
790
+ }
791
+
680
792
  // ─── backfill-covers list ─────────────────────────────────────────────────────
681
793
  function cmdBackfillCovers(rest) {
682
794
  const sub = rest[0];
@@ -684,7 +796,7 @@ function cmdBackfillCovers(rest) {
684
796
  emitJSON({ error: 'unknown-subcommand', message: 'Usage: devlog backfill-covers list --clone <cloneDir> [--project <key>] [--out <staging-dir>] [--all]' }, 2);
685
797
  return;
686
798
  }
687
- const { values } = parseArgs({
799
+ const { values } = safeParseArgs({
688
800
  args: rest.slice(1),
689
801
  options: {
690
802
  clone: { type: 'string' },
@@ -761,7 +873,7 @@ function cmdBackfillCovers(rest) {
761
873
 
762
874
  // ─── cover-context ─────────────────────────────────────────────────────────────
763
875
  function cmdCoverContext(rest) {
764
- const { positionals, values } = parseArgs({
876
+ const { positionals, values } = safeParseArgs({
765
877
  args: rest,
766
878
  options: {
767
879
  clone: { type: 'string' },
@@ -826,7 +938,7 @@ function regenerateContactSheet(outDir) {
826
938
  }
827
939
 
828
940
  async function cmdRenderCover(rest) {
829
- const { positionals, values } = parseArgs({
941
+ const { positionals, values } = safeParseArgs({
830
942
  args: rest,
831
943
  options: {
832
944
  project: { type: 'string' },
@@ -852,17 +964,21 @@ async function cmdRenderCover(rest) {
852
964
  mkdirSync(projectDir, { recursive: true });
853
965
  const pngPath = join(projectDir, `${values.slug}.png`);
854
966
 
855
- // Idempotent re-run: an existing, valid PNG is left untouched no re-render.
856
- if (existsSync(pngPath) && isValidPngFile(pngPath)) {
857
- regenerateContactSheet(outDir);
858
- emitJSON({ ok: true, written: pngPath, rendered: false });
859
- return;
860
- }
861
-
967
+ // The HTML file is the source of truth: whenever it's present, render it —
968
+ // overwriting any stale PNG from a previous attempt. (The old
969
+ // PNG-exists short-circuit silently ignored freshly edited HTML, which cost
970
+ // every real retry loop an ls/mtime/md5 debugging dance and a guessed-at
971
+ // `--force` flag that didn't exist.) Only when the HTML is gone does an
972
+ // existing valid PNG mean "already rendered, nothing to do".
862
973
  let html;
863
974
  try {
864
975
  html = readFileSync(expandHome(htmlFile), 'utf8');
865
976
  } catch (e) {
977
+ if (existsSync(pngPath) && isValidPngFile(pngPath)) {
978
+ regenerateContactSheet(outDir);
979
+ emitJSON({ ok: true, written: pngPath, rendered: false });
980
+ return;
981
+ }
866
982
  emitJSON({ error: 'html-unreadable', message: e.message }, 1);
867
983
  return;
868
984
  }
@@ -878,9 +994,10 @@ async function cmdRenderCover(rest) {
878
994
  }
879
995
  writeFileSync(pngPath, png);
880
996
 
881
- // Transient source document deleted immediately after a successful render only.
882
- try { unlinkSync(expandHome(htmlFile)); } catch { /* best-effort cleanup */ }
883
-
997
+ // The HTML source deliberately stays on disk (it lives in the run's scratch
998
+ // dir and dies with it): keeping it is what makes "tweak the HTML, re-run
999
+ // render-cover" work at all — deleting it on success broke every
1000
+ // post-render Edit attempt in real runs.
884
1001
  regenerateContactSheet(outDir);
885
1002
  emitJSON({ ok: true, written: pngPath, rendered: true });
886
1003
  }
@@ -1018,7 +1135,7 @@ async function cmdCommitCovers(rest) {
1018
1135
 
1019
1136
  // ─── config (view) ───────────────────────────────────────────────────────────
1020
1137
  async function cmdConfig(rest) {
1021
- const { values } = parseArgs({
1138
+ const { values } = safeParseArgs({
1022
1139
  args: rest,
1023
1140
  options: { json: { type: 'boolean', default: false } },
1024
1141
  allowPositionals: false,
@@ -1137,12 +1254,17 @@ Setup & config:
1137
1254
  ${kleur.cyan('npx @natjswenson/devlog config [--json]')} Show current config (with validation)
1138
1255
 
1139
1256
  Used by the /devlog skill:
1140
- ${kleur.cyan('npx @natjswenson/devlog scan [--project <key>]')} JSON plan of new releases needing entries
1141
- ${kleur.cyan('npx @natjswenson/devlog lint-post <file>')} Deterministic post-contract check
1257
+ ${kleur.cyan('npx @natjswenson/devlog scan [--project <key>] [--summary]')} JSON plan of new releases needing entries
1258
+ ${kleur.cyan('npx @natjswenson/devlog lint-post <file> [--voice]')} Deterministic post-contract check (+ voice rules)
1259
+ ${kleur.cyan('npx @natjswenson/devlog assemble-post <draft> --out <dir>')} Extract the draft's code blocks for the run-it check
1142
1260
  ${kleur.cyan('npx @natjswenson/devlog publish-entry ...')} Copy a drafted entry into the clone + update manifest (never overwrites)
1143
1261
  ${kleur.cyan('npx @natjswenson/devlog cover-context <project> <slug> --clone <dir>')} Style guide + reference-image paths for cover composition
1144
1262
  ${kleur.cyan('npx @natjswenson/devlog render-cover <html> --project <key> --slug <s> --out <dir>')} Rasterize a composed cover to PNG
1145
1263
 
1264
+ Editorial maintenance:
1265
+ ${kleur.cyan('npx @natjswenson/devlog tombstone --clone <dir> --project <key> --version <v> --reason <why>')} Retire a moved/consolidated entry's identity
1266
+ ${kleur.cyan('npx @natjswenson/devlog sync-entry --clone <dir> --project <key> --slug <v>')} Resync a manifest row from an edited entry's frontmatter
1267
+
1146
1268
  Backfilling covers onto existing posts:
1147
1269
  ${kleur.cyan('npx @natjswenson/devlog backfill-covers list --clone <dir> [--out <staging-dir>]')} List posts missing a cover
1148
1270
  ${kleur.cyan('npx @natjswenson/devlog commit-covers <staging-dir> [--force [slug]]')} Publish staged covers to already-published entries
@@ -1196,6 +1318,15 @@ if (isMain) {
1196
1318
  case 'publish-entry':
1197
1319
  cmdPublishEntry(rest);
1198
1320
  break;
1321
+ case 'tombstone':
1322
+ cmdTombstone(rest);
1323
+ break;
1324
+ case 'sync-entry':
1325
+ cmdSyncEntry(rest);
1326
+ break;
1327
+ case 'assemble-post':
1328
+ cmdAssemblePost(rest);
1329
+ break;
1199
1330
  case 'backfill-covers':
1200
1331
  cmdBackfillCovers(rest);
1201
1332
  break;
@@ -34,6 +34,8 @@ function validateManifest(data) {
34
34
  const entries = [];
35
35
  for (const e of data.entries) {
36
36
  if (!e || typeof e !== 'object') continue;
37
+ // Tombstoned rows (removed: true) are editorial retirements, not entries.
38
+ if (e.removed) continue;
37
39
  const { date, file, title, summary, version } = e;
38
40
  if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
39
41
  if (typeof file !== 'string' || !/^[a-zA-Z0-9._-]+\.md$/.test(file)) continue;
@@ -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.0",
4
4
  "description": "Release dev log generator \u2014 Claude Code skill + preview app for publishing version-release dev logs, written in your voice, to your site",
5
5
  "license": "MIT",
6
6
  "author": "Nate Swenson",
@@ -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,5 @@
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": ["scan", "lint-post", "publish-entry", "add-project", "remove-project", "set", "config", "init", "cover-context", "render-cover", "tombstone", "sync-entry", "assemble-post"]
99
119
  }