@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/CHANGELOG.md +479 -0
- package/LICENSE +21 -0
- package/README.md +272 -0
- package/SKILL.md +132 -79
- package/bin/devlog.js +160 -29
- package/evals/baseline/published/devlog-v0.10.0.md +129 -0
- package/evals/baseline/published/devlog-v0.11.0.md +139 -0
- package/evals/baseline/published/devlog-v0.8.1.md +97 -0
- package/evals/baseline/published/devlog-v0.9.0.md +193 -0
- package/evals/baseline/published/ghostwriter-v0.11.0.md +211 -0
- package/evals/baseline/published/ghostwriter-v0.8.1.md +221 -0
- package/evals/baseline/published/local-fitness-v0.25.0.md +233 -0
- package/evals/baseline/published/resume-v1.0.1.md +129 -0
- 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 +4 -2
- package/skill-invariants.json +46 -1
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;
|
|
@@ -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))
|