@natjswenson/devlog 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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;
@@ -0,0 +1,129 @@
1
+ ---
2
+ title: "Folding a content repo into the site repo without breaking idempotent publishing"
3
+ date: 2026-07-19
4
+ project: devlog
5
+ version: v0.10.0
6
+ tags: [github-api, gh-cli, idempotency, cloudflare-pages, static-sites, content-pipeline, release-engineering, shell]
7
+ summary: "Moving generated content into a subfolder of the site repo turns every publish push into a deploy. The catch: your publisher's already-published check is full of path assumptions, and missing one silently re-plans your entire archive."
8
+ ---
9
+
10
+ ## Shipped
11
+
12
+ devlog v0.10.0 adds a `targetDir` setting: the publisher can now write its content tree into a subdirectory of the target repo instead of the repo root. That's the piece that let me delete a whole repo from my pipeline; devlog entries now land in `content/devlog/` inside the site repo itself, and the push that publishes them is the same push that deploys the site. The release also rewrote the cover style guide for the site's PRESS brand (including a fill floor for the hero illustration) and froze entry numbers at publish time so a backdated entry can't renumber the archive. This post is about the migration pattern: what an idempotent publisher checks before it writes, and every place a path prefix has to reach when the content moves.
13
+
14
+ ## The two-repo tax
15
+
16
+ The old shape was a dedicated content repo. The generator pushed markdown, a manifest, and a cover PNG there; the site fetched it all at build time. Workable, but every publish needed a second step, an empty "rebuild" commit to the site repo, because [Cloudflare Pages triggers a deployment on commits to the production branch](https://developers.cloudflare.com/pages/configuration/branch-build-controls/) of the repo it watches, and content landing in a *different* repo doesn't count.
17
+
18
+ Move the content into the site repo and that second step disappears. The site reads the files from disk at build time, and the publish push is the deploy trigger. The only real engineering is in the publisher: it has to stay idempotent when its target is no longer a repo root.
19
+
20
+ ## The check that makes publishing idempotent
21
+
22
+ A release publisher should be safe to re-run. Mine plans work by listing what's already published and diffing against local git tags, one directory listing per project via the [GitHub contents API](https://docs.github.com/en/rest/repos/contents): `GET /repos/{owner}/{repo}/contents/{path}` returns an array of entries when `path` is a directory, and `ref` pins the branch. Through [`gh api`](https://cli.github.com/manual/gh_api) that's a one-liner, authenticated with your existing CLI login:
23
+
24
+ ```bash
25
+ gh api "repos/OWNER/REPO/contents/PROJECT?ref=main" --jq '.[].name'
26
+ ```
27
+
28
+ The Node version, with the two failure modes that matter kept distinct:
29
+
30
+ ```js
31
+ // existing.mjs
32
+ import { spawnSync } from 'node:child_process';
33
+
34
+ // Which entry files already exist for one project.
35
+ // Returns { files: Set, status: 'ok' | 'empty' | 'failed' }.
36
+ export function fetchExistingEntries(repo, branch, projectKey, targetDir = '') {
37
+ const contentPath = targetDir ? `${targetDir}/${projectKey}` : projectKey;
38
+ const r = spawnSync('gh', ['api', `repos/${repo}/contents/${contentPath}?ref=${branch}`, '--jq', '.[].name'], { encoding: 'utf8' });
39
+ if (r.status === 0) {
40
+ return { files: new Set(r.stdout.split('\n').filter(Boolean)), status: 'ok' };
41
+ }
42
+ // 404 means "this project has no entries yet"; a normal state for a new
43
+ // project, not an error. Anything else means the check itself failed and
44
+ // the caller should know its already-published filter may be incomplete.
45
+ if (/HTTP 404|Not Found/i.test(r.stderr)) {
46
+ return { files: new Set(), status: 'empty' };
47
+ }
48
+ return { files: new Set(), status: 'failed' };
49
+ }
50
+ ```
51
+
52
+ The `targetDir` parameter is the whole feature. Without it, moving content to `content/devlog/` means the check asks GitHub for `contents/ghostwriter` at the repo root, gets a 404, concludes "no entries yet", and the planner happily re-plans every release you've ever published. Fifty-five posts, in my case, all queued for regeneration against a publisher that would then refuse each one.
53
+
54
+ ## Thread the prefix everywhere, and validate it
55
+
56
+ A path that ends up inside shell commands and API URLs earns strict validation at config time:
57
+
58
+ ```js
59
+ // config.mjs
60
+ export function validateTargetDir(targetDir) {
61
+ if (
62
+ typeof targetDir !== 'string' ||
63
+ !/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/.test(targetDir) ||
64
+ targetDir.split('/').some((s) => s === '.' || s === '..')
65
+ ) {
66
+ throw new Error(`targetDir must be a relative path like "content/devlog": got ${JSON.stringify(targetDir)}`);
67
+ }
68
+ return targetDir;
69
+ }
70
+ ```
71
+
72
+ Relative only, tight charset, no traversal segments. The regex alone would accept `..` as a "word", so the explicit segment check backs it up.
73
+
74
+ Then find every consumer of the old repo-root assumption. In my publisher there were three:
75
+
76
+ ```js
77
+ // scan.mjs: the planner threads targetDir into the existence check and
78
+ // echoes it in its output, so the publish step can build paths from the
79
+ // same value instead of re-reading config.
80
+ const existing = fetchExistingEntries(config.targetRepo, branch, project.key, config.targetDir || '');
81
+ ```
82
+
83
+ The other two live in the publish step. The writer's content root becomes `<clone>/<targetDir>` while git still operates on the clone root; and any "view it here" URL you print needs the prefix too:
84
+
85
+ ```bash
86
+ git clone --depth=1 "https://github.com/OWNER/REPO.git" "$TMP/REPO"
87
+ CONTENT_ROOT="$TMP/REPO/content/devlog" # writers target this
88
+ # ... write PROJECT/vX.Y.Z.md, PROJECT/manifest.json, PROJECT/vX.Y.Z.png under $CONTENT_ROOT ...
89
+ git -C "$TMP/REPO" add . && git -C "$TMP/REPO" commit -m 'devlog: add release entries'
90
+ git -C "$TMP/REPO" push origin main # on Cloudflare Pages, this IS the deploy
91
+ ```
92
+
93
+ ## Cut over and verify
94
+
95
+ The safe order matters: land the content move in the site repo first, deploy it, and only then point the publisher at the new target; the existence check reads the target's live branch, so flipping config before the content exists there recreates the 404-means-empty re-planning problem on purpose.
96
+
97
+ After flipping, one scan tells you whether the prefix reached everywhere. This is the real output from my cutover:
98
+
99
+ ```text
100
+ targetRepo: natejswenson/natejswenson.io | targetDir: content/devlog | branch: main
101
+ totalNewReleases: 5
102
+ local-fitness: existenceCheck=ok new=0
103
+ devlog: existenceCheck=ok new=2
104
+ ghostwriter: existenceCheck=ok new=2
105
+ resume: existenceCheck=ok new=0
106
+ local-budget: existenceCheck=ok new=0
107
+ personal: existenceCheck=ok new=0
108
+ ```
109
+
110
+ Every project resolves `ok` through the subfolder, and the only "new" releases are genuinely untagged ones. If you see a fully published project reporting `new=<its entire history>`, the prefix missed the existence check.
111
+
112
+ ## Gotchas
113
+
114
+ - **The missed-prefix failure is silent and looks like work to do.** Trap: any consumer of the old root-relative path that you didn't update. Symptom: not an error; the scan cheerfully reports your whole archive as new releases. Escape: after any path change, run the planner against the live target and assert the re-plan count is zero before letting anything publish.
115
+ - **404 is a state, not a failure.** Trap: treating every non-200 from the contents API the same. Symptom: either a brand-new project blocks publishing (404 treated as failure) or a real outage quietly re-plans everything (failure treated as empty). Escape: three-way status, as in `fetchExistingEntries` above; only 404 means empty.
116
+ - **The check is a filter, not the safety.** Trap: trusting the remote listing as the last line of defense. Symptom: a degraded check plus an overwrite-happy writer equals clobbered history. Escape: the writer itself must refuse to overwrite an existing entry against the fresh clone; then a failed existence check degrades to wasted planning, never to data loss.
117
+ - **zsh eats the `?` in the API path.** Trap: pasting `gh api repos/o/r/contents/x?ref=main` unquoted into zsh while debugging. Symptom: `no matches found: repos/...` before gh even runs, because `?` is a glob character. Escape: quote the whole endpoint argument; I hit this within an hour of shipping the feature.
118
+ - **Directory listings cap at 1,000 files.** Trap: one flat directory per project, forever. Symptom: the [contents API stops listing past 1,000 entries](https://docs.github.com/en/rest/repos/contents) and the existence filter goes blind. Escape: at that scale, switch the check to the Git Trees API; per-project directories buy a lot of headroom first.
119
+
120
+ ## Sources
121
+
122
+ - [GitHub REST API: repository contents](https://docs.github.com/en/rest/repos/contents) — directory responses are arrays, `ref` pins the branch, 404 for missing paths, 1,000-file listing cap
123
+ - [Cloudflare Pages: branch build controls](https://developers.cloudflare.com/pages/configuration/branch-build-controls/) — deployments trigger on commits to the production branch
124
+ - [gh api manual](https://cli.github.com/manual/gh_api) — authenticated API calls from the CLI, `--jq` for response filtering
125
+
126
+ ## Changelog
127
+
128
+ - feat(devlog): targetDir — publish the content tree into a subdirectory of targetRepo (#84) ([5bfada2](https://github.com/natejswenson/claude-skills/commit/5bfada2bfd3d0a3826cb78c1eed0056d71436840))
129
+ - feat(devlog): PRESS cover style guide + frozen entry numbers ([2627a4c](https://github.com/natejswenson/claude-skills/commit/2627a4ce8cd59dfd95010e49f0a274c23abce289))
@@ -0,0 +1,139 @@
1
+ ---
2
+ title: "Auditing an AI skill against its own past runs"
3
+ date: 2026-07-19
4
+ project: devlog
5
+ version: v0.11.0
6
+ tags: [ai-agents, agent-evaluation, postmortems, claude-code, subagents, git-forensics, guardrails, transcripts]
7
+ summary: "Version 0.11.0 of this skill came entirely out of one exercise: asking the model to review the skill's own previous six runs. Here is the four-lens audit loop that produced it, and what the round cost in tokens."
8
+ ---
9
+
10
+ ## Shipped
11
+
12
+ devlog 0.11.0 is a batch of guardrails: tombstones so a deleted entry can never republish itself, a ground-truth gate that verifies every claim about my own repos against git before publishing, a mechanical version of the run-the-code check, deterministic voice linting, and a fix for a CLI command that silently ignored fresh input. None of it came from a feature idea. All of it came from asking the model to review the skill's own past six runs. That review loop is the technique worth teaching; this guide walks through running it on your own agent skill.
13
+
14
+ ## The four lenses
15
+
16
+ An agent skill drifts quietly. No single run fails hard enough to file a bug, but small problems repeat until they are load-bearing. The fix is the same discipline SRE applies to incidents: document what happened, understand the causes, and put preventive actions in place so it stops recurring ([Google SRE book, postmortem culture](https://sre.google/sre-book/postmortem-culture/)). The difference is that your "incident" is spread across runs, so you have to go collect it.
17
+
18
+ I review on four dimensions, each answering one question:
19
+
20
+ - **Accuracy**: did the runs do what the skill promises? For a writing skill, are the claims in the output true?
21
+ - **Completeness**: what did the runs miss, and what did a human have to clean up afterward?
22
+ - **Efficiency**: where did tokens and wall-clock time go that produced no value?
23
+ - **Agent UX**: where did the skill's own instructions or tools fight the agent executing them?
24
+
25
+ The last one surprises people. The agent is a user of your skill, and it hits usability bugs the same way humans do; it just cannot file a complaint. You find those bugs in transcripts.
26
+
27
+ ## Three evidence streams
28
+
29
+ A run leaves three artifact trails, and each lens needs a different one.
30
+
31
+ First, the outputs themselves: whatever the skill publishes. Grade a sample against the skill's own quality contract, and verify factual claims against the system of record instead of taking the output's word for it.
32
+
33
+ Second, the correction commits. If your skill writes into a git repo, every manual fix a human made after a run is a finding with a timestamp. Separate the skill's own commits from everything else:
34
+
35
+ ```bash
36
+ # Commits the skill's happy path writes (use your skill's commit message):
37
+ git log --date=short --pretty='%h %ad %s' --grep='add release entries'
38
+
39
+ # Everything a human had to do around them:
40
+ git log --date=short --pretty='%h %ad %s' --invert-grep --grep='add release entries'
41
+ ```
42
+
43
+ Running that against my dev-log repo (output trimmed to the signal lines):
44
+
45
+ ```text
46
+ 9b26494 2026-07-18 devlog: add release entries
47
+ 8f8938a 2026-07-17 devlog: add release entries
48
+ ...
49
+ 83cd9de 2026-07-18 chore: assign permanent entry numbers to all 55 entries
50
+ c160db1 2026-07-17 devlog: file market-research v0.1.0 entry under personal
51
+ ```
52
+
53
+ The second list held the review's best material: an entry reverted the same day a run re-added it, three posts consolidated into one by hand, and a moved file (that `c160db1` line) that the next run would have silently regenerated. Manual cleanup is the skill telling you what it cannot do yet.
54
+
55
+ Third, the transcripts. Claude Code stores session logs as JSONL under `~/.claude/projects/`, one directory per working directory. Find the sessions where your skill ran by grepping for a command only it uses:
56
+
57
+ ```bash
58
+ grep -l 'devlog scan --json' ~/.claude/projects/*/*.jsonl
59
+ ```
60
+
61
+ Then pull rough per-run metrics. Transcript size is a fair cost proxy, and counting CLI invocations or retries shows where the loop stalled:
62
+
63
+ ```bash
64
+ f=$(grep -l 'devlog scan --json' ~/.claude/projects/*/*.jsonl | head -1)
65
+ wc -c < "$f" # transcript bytes, a rough cost proxy
66
+ grep -c '"name":"Bash"' "$f" # shell tool calls in the session
67
+ ```
68
+
69
+ ```text
70
+ 2762710
71
+ 95
72
+ ```
73
+
74
+ ## Fan out reviewers that can touch ground truth
75
+
76
+ One reviewer reading everything runs out of context and blends the lenses together. I dispatch one subagent per evidence stream and give each a narrow brief plus access to the real repos. The prompt shape matters more than the wording:
77
+
78
+ ```markdown
79
+ You are auditing the output quality of the <skill> agent skill.
80
+ The skill's contract is at <path to SKILL.md>; read it first.
81
+
82
+ For each published output:
83
+ 1. Grade it against the contract, point by point.
84
+ 2. Verify its factual claims against the SOURCE repo's git history
85
+ (tags, diffs, commit messages), not against the output's own text.
86
+ 3. Report patterns across outputs, each with concrete evidence
87
+ (file, commit hash, or quote).
88
+
89
+ Be skeptical and specific; this feeds a fix list, not a report card.
90
+ ```
91
+
92
+ Point 2 is the one you cannot skip. My accuracy reviewer found a published post whose central premise was false: it claimed two of four packages in a release never got git tags, and a single `git tag -l` showed all four tags existed. The post had passed the skill's self-review, because self-review graded the draft against the writing contract, never against the repo. A reviewer without repo access would have graded the same lie the same way.
93
+
94
+ ## Ship findings as gates, then re-run
95
+
96
+ A review that produces a document has not improved anything yet. The SRE postmortem bar applies: findings become prioritized action items or they are theater ([postmortem culture](https://sre.google/sre-book/postmortem-culture/)). For agent skills I hold a stricter line: every finding ships as a mechanical gate in the next version, because an instruction the agent is supposed to remember is exactly the thing the review just proved gets skipped. Reflexion showed that agents improve when reflections persist somewhere durable instead of evaporating with the episode ([Shinn et al., 2023](https://arxiv.org/abs/2303.11366)); for a skill, the durable place is the code path the agent cannot route around.
97
+
98
+ Two examples from this round. The false-premise finding became a required pre-publish step: list every claim the draft makes about your own repo, then verify each with a git command run in that session, and delete what you cannot verify. The cleanup-commit findings became identity guardrails; a retired artifact now keeps a tombstone row in its manifest, and the publish path refuses it:
99
+
100
+ ```json
101
+ { "version": "v0.1.0", "file": "v0.1.0.md", "removed": true,
102
+ "reason": "consolidated into the 2026-07-17 entry" }
103
+ ```
104
+
105
+ ```js
106
+ function refuseTombstoned(manifest, version) {
107
+ const tombstoned = manifest.entries.find(
108
+ (e) => e.removed && e.version === version,
109
+ );
110
+ if (tombstoned) {
111
+ throw new Error(
112
+ `${version} was editorially retired (${tombstoned.reason}); refusing to republish.`,
113
+ );
114
+ }
115
+ }
116
+ ```
117
+
118
+ Transcript findings usually fix the tool, not the prompt. Anthropic's agent guidance says to invest in the agent-computer interface the way you would invest in UI design ([Building effective agents](https://www.anthropic.com/engineering/building-effective-agents)), and my transcripts proved why: a render command that silently no-opped when its output already existed cost the agent a multi-call debugging dance in two separate runs. The fix was one reordered check in the CLI, worth more than any added instruction.
119
+
120
+ Then re-run the skill. The next real run is the acceptance test: this post was generated by the version the audit produced, its opening scan reported the new CLI version, and the retired entry scanned as tombstoned instead of resurfacing as a new release.
121
+
122
+ On cost, since I had never tallied it: the three review subagents in this round reported 337k tokens between them, and the three code-exploration agents that turned findings into an implementation plan reported another 273k. Call the whole round about 600k subagent tokens plus the main session. That is real money, and it bought eighteen files of shipped fixes; I would not spend it weekly, but per release milestone it has paid for itself every time.
123
+
124
+ ## Gotchas
125
+
126
+ - **Self-review grades the essay, not the facts.** Trap: letting the skill's quality check compare the draft to a rubric while every factual claim goes unchecked. Symptom: a confident, well-structured output with a false premise sails through. Escape: give reviewers (and the skill itself) repo access and require a verification command per claim; a claim you cannot verify gets removed, not softened.
127
+ - **Honor-system steps are skipped exactly when they matter.** Trap: writing "run the code blocks and check the output" as an instruction. Symptom: outputs that say "this is the real output" over code that cannot run; my audit found one whose demo files were never defined anywhere. Escape: turn the instruction into a command the agent runs (mine extracts a draft's code blocks into numbered files), so skipping it becomes visible instead of silent.
128
+ - **Output-only reviews miss tool friction entirely.** Trap: auditing what the skill produced and never how the agent got there. Symptom: the outputs look fine while every run quietly burns calls fighting the same CLI quirk. Escape: make transcripts a first-class evidence stream; the silent no-op I fixed in 0.11.0 appeared in zero outputs and two transcripts.
129
+ - **The review has a real price and nobody is tracking it.** Trap: running fan-out reviews on a schedule without measuring them. Symptom: a surprise in the usage dashboard. Escape: pull the token counts from your platform's subagent usage reports while the round is fresh, and set the cadence from the number instead of a hunch.
130
+
131
+ ## Sources
132
+
133
+ - [Google SRE Book: Postmortem Culture](https://sre.google/sre-book/postmortem-culture/) — blameless postmortems, and findings becoming prioritized preventive action
134
+ - [Anthropic: Building effective agents](https://www.anthropic.com/engineering/building-effective-agents) — measure and iterate; invest in the agent-computer interface like UI design
135
+ - [Reflexion: Language Agents with Verbal Reinforcement Learning](https://arxiv.org/abs/2303.11366) — agents improve when reflections persist in durable memory across episodes
136
+
137
+ ## Changelog
138
+
139
+ - feat(devlog): 0.11.0 — tombstones, ground-truth gate, and the six-run audit fixes (#86) ([bd8fa5d](https://github.com/natejswenson/claude-skills/commit/bd8fa5d88a8c33dbf6f13ed64394b0682b4b8f4b))