@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 +132 -79
- package/bin/devlog.js +160 -29
- package/examples/react/useDevLogEntries.js +2 -0
- package/lib/assemble_post.mjs +71 -0
- package/lib/cover_gen.mjs +6 -1
- package/lib/lint_post.mjs +47 -1
- package/lib/publish_entry.mjs +209 -12
- package/lib/scan.mjs +95 -16
- package/package.json +1 -1
- package/skill-invariants.json +21 -1
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:
|
|
75
|
-
releases (version + date +
|
|
76
|
-
`prerelease`, `empty-range`, etc. — omit `entry-exists`
|
|
77
|
-
`tagFetch: "failed"` ("using local tags only") and
|
|
78
|
-
confirm which entries exist; publish will still
|
|
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 #
|
|
86
|
-
npx -y @natjswenson/devlog scan --json --project '<key>'
|
|
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
|
-
|
|
90
|
-
`
|
|
91
|
-
|
|
92
|
-
settings (`minSources`, `topicDomains`).
|
|
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.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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.
|
|
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. **
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
the
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
npx -y @natjswenson/devlog publish-entry \
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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-Write — if 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,
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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
|
-
|
|
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 } =
|
|
630
|
+
const { values, positionals } = safeParseArgs({
|
|
610
631
|
args: rest,
|
|
611
|
-
options: {
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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
|
-
//
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
//
|
|
882
|
-
|
|
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 } =
|
|
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>')}
|
|
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)
|
|
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
|
}
|
package/lib/publish_entry.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
70
|
-
//
|
|
71
|
-
// this function stays testable against
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
//
|
|
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 {
|
|
252
|
+
return { ...emptyExisting(), status: 'empty' };
|
|
202
253
|
}
|
|
203
|
-
return {
|
|
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
|
-
|
|
223
|
-
|
|
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.
|
|
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",
|
package/skill-invariants.json
CHANGED
|
@@ -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
|
}
|