@natjswenson/devlog 0.13.0 → 0.14.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/CHANGELOG.md +23 -0
- package/README.md +230 -120
- package/SKILL.md +54 -4
- package/bin/devlog.js +99 -0
- package/config.example.json +1 -0
- package/image-style/style-guide.example.md +10 -10
- package/lib/compose_art_cover.mjs +144 -0
- package/lib/config_ops.mjs +1 -0
- package/lib/core.mjs +3 -0
- package/lib/guide_draft.mjs +134 -0
- package/lib/guide_preview.css +3 -0
- package/lib/publish_guide.mjs +124 -0
- package/package.json +4 -3
- package/references/codex-cover-art.md +119 -0
- package/references/concept-guides.md +148 -0
- package/references/cover-spec.md +40 -0
- package/references/guide-publishing.md +245 -0
- package/skill-invariants.json +31 -10
package/bin/devlog.js
CHANGED
|
@@ -393,6 +393,16 @@ async function cmdInit() {
|
|
|
393
393
|
}
|
|
394
394
|
|
|
395
395
|
if (await confirmOverwrite('SKILL.md', SKILL_DEST)) {
|
|
396
|
+
// These are versioned instructions, not personal config/voice/style files.
|
|
397
|
+
// Install references with the entrypoint so standalone hosts can resolve them.
|
|
398
|
+
const references = join(PACKAGE_ROOT, 'references');
|
|
399
|
+
const referenceDest = join(CONFIG_DIR, 'references');
|
|
400
|
+
mkdirSync(referenceDest, { recursive: true, mode: 0o700 });
|
|
401
|
+
for (const name of readdirSync(references)) {
|
|
402
|
+
if (name.endsWith('.md') && statSync(join(references, name)).isFile()) {
|
|
403
|
+
copyFileSync(join(references, name), join(referenceDest, name));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
396
406
|
copyFileSync(SKILL_SRC, SKILL_DEST);
|
|
397
407
|
log.ok(`Installed SKILL.md → ${SKILL_DEST}`);
|
|
398
408
|
} else {
|
|
@@ -789,6 +799,79 @@ function cmdAssemblePost(rest) {
|
|
|
789
799
|
}
|
|
790
800
|
}
|
|
791
801
|
|
|
802
|
+
// Additive local draft helpers. No config reads, generation, or content writes.
|
|
803
|
+
async function cmdLintGuide(rest) {
|
|
804
|
+
const { values, positionals } = safeParseArgs({
|
|
805
|
+
args: rest, options: { voice: { type: 'boolean', default: false } }, allowPositionals: true,
|
|
806
|
+
});
|
|
807
|
+
if (positionals.length !== 1) emitJSON({ error: 'missing-arg', message: 'Usage: devlog lint-guide <article> [--voice]' }, 2);
|
|
808
|
+
try {
|
|
809
|
+
const { lintGuide } = await import('../lib/guide_draft.mjs');
|
|
810
|
+
const result = lintGuide(readFileSync(expandHome(positionals[0]), 'utf8'), { voice: values.voice });
|
|
811
|
+
emitJSON(result, result.ok ? 0 : 1);
|
|
812
|
+
} catch (e) {
|
|
813
|
+
emitJSON({ error: e.code || 'guide-lint-failed', message: e.message }, 1);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function cmdPrepareGuide(rest) {
|
|
818
|
+
const { values } = safeParseArgs({
|
|
819
|
+
args: rest,
|
|
820
|
+
options: { article: { type: 'string' }, brand: { type: 'string' }, out: { type: 'string' }, cover: { type: 'string' } },
|
|
821
|
+
allowPositionals: false,
|
|
822
|
+
});
|
|
823
|
+
for (const flag of ['article', 'brand', 'out']) {
|
|
824
|
+
if (!values[flag]) emitJSON({ error: 'missing-flag', message: `prepare-guide requires --${flag}` }, 2);
|
|
825
|
+
}
|
|
826
|
+
try {
|
|
827
|
+
const { prepareGuidePreview } = await import('../lib/guide_draft.mjs');
|
|
828
|
+
const result = await prepareGuidePreview({
|
|
829
|
+
articlePath: expandHome(values.article), brandPath: expandHome(values.brand),
|
|
830
|
+
outDir: expandHome(values.out), coverPath: values.cover ? expandHome(values.cover) : undefined,
|
|
831
|
+
});
|
|
832
|
+
emitJSON({ ok: true, ...result });
|
|
833
|
+
} catch (e) {
|
|
834
|
+
emitJSON({ error: e.code || 'guide-preview-failed', message: e.message, ...(e.findings ? { findings: e.findings } : {}) }, 1);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async function cmdComposeArtCover(rest) {
|
|
839
|
+
const { values } = safeParseArgs({
|
|
840
|
+
args: rest, options: { spec: { type: 'string' }, out: { type: 'string' } }, allowPositionals: false,
|
|
841
|
+
});
|
|
842
|
+
for (const flag of ['spec', 'out']) {
|
|
843
|
+
if (!values[flag]) emitJSON({ error: 'missing-flag', message: `compose-art-cover requires --${flag}` }, 2);
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
const { composeArtCover } = await import('../lib/compose_art_cover.mjs');
|
|
847
|
+
const result = await composeArtCover(expandHome(values.spec), expandHome(values.out));
|
|
848
|
+
emitJSON({ ok: true, ...result });
|
|
849
|
+
} catch (e) {
|
|
850
|
+
emitJSON({ error: e.code || 'art-compose-failed', message: e.message }, 1);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function cmdPublishGuide(rest) {
|
|
855
|
+
const { values } = safeParseArgs({
|
|
856
|
+
args: rest,
|
|
857
|
+
options: { clone: { type: 'string' }, article: { type: 'string' }, evidence: { type: 'string' }, cover: { type: 'string' } },
|
|
858
|
+
allowPositionals: false,
|
|
859
|
+
});
|
|
860
|
+
for (const flag of ['clone', 'article', 'evidence']) {
|
|
861
|
+
if (!values[flag]) emitJSON({ error: 'missing-flag', message: `publish-guide requires --${flag}` }, 2);
|
|
862
|
+
}
|
|
863
|
+
try {
|
|
864
|
+
const { publishGuide } = await import('../lib/publish_guide.mjs');
|
|
865
|
+
const result = await publishGuide({
|
|
866
|
+
cloneDir: expandHome(values.clone), articlePath: expandHome(values.article),
|
|
867
|
+
evidencePath: expandHome(values.evidence), coverPath: values.cover ? expandHome(values.cover) : undefined,
|
|
868
|
+
});
|
|
869
|
+
emitJSON(result);
|
|
870
|
+
} catch (e) {
|
|
871
|
+
emitJSON({ error: e.code || 'guide-publish-failed', message: e.message, ...(e.findings ? { findings: e.findings } : {}) }, 1);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
792
875
|
// ─── backfill-covers list ─────────────────────────────────────────────────────
|
|
793
876
|
function cmdBackfillCovers(rest) {
|
|
794
877
|
const sub = rest[0];
|
|
@@ -1257,6 +1340,10 @@ Used by the /devlog skill:
|
|
|
1257
1340
|
${kleur.cyan('npx @natjswenson/devlog scan [--project <key>] [--summary]')} JSON plan of new releases needing entries
|
|
1258
1341
|
${kleur.cyan('npx @natjswenson/devlog lint-post <file> [--voice]')} Deterministic post-contract check (+ voice rules)
|
|
1259
1342
|
${kleur.cyan('npx @natjswenson/devlog assemble-post <draft> --out <dir>')} Extract the draft's code blocks for the run-it check
|
|
1343
|
+
${kleur.cyan('devlog lint-guide <article> [--voice]')} Check a concept draft and its top-of-post handoff
|
|
1344
|
+
${kleur.cyan('devlog prepare-guide --article <md> --brand <json> --out <new-dir> [--cover <png>]')} Local reading preview with complete agent payload
|
|
1345
|
+
${kleur.cyan('devlog compose-art-cover --spec <json> --out <new-dir>')} Compose local raster art and typography; no AI call or publishing
|
|
1346
|
+
${kleur.cyan('devlog publish-guide --clone <content-root> --article <md> --evidence <json> [--cover <png>]')} Validate evidence and publish into clone; no push
|
|
1260
1347
|
${kleur.cyan('npx @natjswenson/devlog publish-entry ...')} Copy a drafted entry into the clone + update manifest (never overwrites)
|
|
1261
1348
|
${kleur.cyan('npx @natjswenson/devlog cover-context <project> <slug> --clone <dir>')} Style guide + reference-image paths for cover composition
|
|
1262
1349
|
${kleur.cyan('npx @natjswenson/devlog render-cover <html> --project <key> --slug <s> --out <dir>')} Rasterize a composed cover to PNG
|
|
@@ -1327,6 +1414,18 @@ if (isMain) {
|
|
|
1327
1414
|
case 'assemble-post':
|
|
1328
1415
|
cmdAssemblePost(rest);
|
|
1329
1416
|
break;
|
|
1417
|
+
case 'lint-guide':
|
|
1418
|
+
cmdLintGuide(rest);
|
|
1419
|
+
break;
|
|
1420
|
+
case 'prepare-guide':
|
|
1421
|
+
cmdPrepareGuide(rest);
|
|
1422
|
+
break;
|
|
1423
|
+
case 'publish-guide':
|
|
1424
|
+
await cmdPublishGuide(rest);
|
|
1425
|
+
break;
|
|
1426
|
+
case 'compose-art-cover':
|
|
1427
|
+
cmdComposeArtCover(rest);
|
|
1428
|
+
break;
|
|
1330
1429
|
case 'backfill-covers':
|
|
1331
1430
|
cmdBackfillCovers(rest);
|
|
1332
1431
|
break;
|
package/config.example.json
CHANGED
|
@@ -165,16 +165,16 @@ site, not a marketing graphic and not a repeated template.
|
|
|
165
165
|
|
|
166
166
|
### Palette
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
- **
|
|
170
|
-
|
|
171
|
-
- **Dim** `#6E675C` — secondary text
|
|
172
|
-
- **
|
|
173
|
-
|
|
174
|
-
- **
|
|
175
|
-
- **
|
|
176
|
-
|
|
177
|
-
|
|
168
|
+
<!-- >>> press:palette v0.9.0 sha256:5904c52d4168 GENERATED by @natjswenson/press, do not edit -->
|
|
169
|
+
- **Paper** `#F5F0E6` — Warm cream. Flat — never gradiented, never textured.
|
|
170
|
+
- **Ink** `#181510` — Near-black. Text, headlines, and every structural rule.
|
|
171
|
+
- **Dim** `#6E675C` — Muted secondary text; the serif commentary voice's color.
|
|
172
|
+
- **Accent** `#E8501F` — THE one loud color. Spent once or twice per document, never as decoration.
|
|
173
|
+
- **Ink Mid** `#4A423A` — Mid ink step: a second series, a stacked-bar segment, a bar track.
|
|
174
|
+
- **Ink Faint** `#8A8272` — Decorative only — a faint element inside an illustration. Never body text, never a headline.
|
|
175
|
+
- **Terminal panel** (bg, text, dim, hot, prompt) `#141A26`, `#EFE9DC`, `#8A8478`, `#FF8A5C`, `#1E2738` — the one place the dark palette
|
|
176
|
+
survives, and only inside a terminal element. Never on paper.
|
|
177
|
+
<!-- <<< press:palette -->
|
|
178
178
|
|
|
179
179
|
Prefer flat, limited color and solid/line fills over gradients or smooth shading — the
|
|
180
180
|
render is compressed with lossy PNG palette quantization afterward, and gradients band
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Additive, offline raster-art compositor. Success records rendering, never visual approval.
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import { chromium } from 'playwright';
|
|
7
|
+
|
|
8
|
+
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
const escape = value => value.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
10
|
+
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
11
|
+
function string(value, field, max, optional = false) {
|
|
12
|
+
if (optional && value === undefined) return '';
|
|
13
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\u0000-\u001f]/.test(value)) {
|
|
14
|
+
throw fail('ART_SPEC_INVALID', `${field} must be nonempty text of at most ${max} characters without control characters`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function local(value, field, base) {
|
|
19
|
+
string(value, field, 4096);
|
|
20
|
+
if (!path.isAbsolute(value) && /^[a-z][a-z0-9+.-]*:/i.test(value)) throw fail('ART_SPEC_INVALID', `${field} must be a local filesystem path`);
|
|
21
|
+
return path.resolve(base, value);
|
|
22
|
+
}
|
|
23
|
+
async function json(file, field) {
|
|
24
|
+
const bytes = await readFile(file);
|
|
25
|
+
try { return { bytes, value: JSON.parse(bytes.toString('utf8')) }; }
|
|
26
|
+
catch { throw fail('ART_SPEC_INVALID', `${field} must contain valid JSON`); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Schema 1: {schema:1, source, brand, title, kicker?, stand?, fontPath?}.
|
|
30
|
+
* Paths in the spec resolve against its directory; outDir resolves against cwd.
|
|
31
|
+
* Output directory must not exist. On failure it may contain partial artifacts,
|
|
32
|
+
* but never result.json; the compositor never deletes any directory.
|
|
33
|
+
*/
|
|
34
|
+
export async function composeArtCover(specPath, outDir) {
|
|
35
|
+
try { return await compose(specPath, outDir); }
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code?.startsWith('ART_')) throw error;
|
|
38
|
+
throw fail('ART_COMPOSE_FAILED', `Art cover failed: ${error.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function compose(specPath, outDir) {
|
|
42
|
+
const resolvedSpec = path.resolve(string(specPath, 'specPath', 4096));
|
|
43
|
+
const destination = path.resolve(string(outDir, 'outDir', 4096));
|
|
44
|
+
const base = path.dirname(resolvedSpec);
|
|
45
|
+
const { bytes: specBytes, value: spec } = await json(resolvedSpec, 'spec');
|
|
46
|
+
if (!spec || spec.schema !== 1 || Array.isArray(spec)) throw fail('ART_SPEC_INVALID', 'spec.schema must be 1');
|
|
47
|
+
const known = new Set(['schema', 'source', 'brand', 'title', 'kicker', 'stand', 'fontPath']);
|
|
48
|
+
if (Object.keys(spec).some(key => !known.has(key))) throw fail('ART_SPEC_INVALID', 'Unknown spec field; HTML/CSS input is not supported');
|
|
49
|
+
const title = string(spec.title, 'title', 300);
|
|
50
|
+
const kicker = string(spec.kicker, 'kicker', 80, true) || 'ENGINEERING FIELD NOTES';
|
|
51
|
+
const stand = string(spec.stand, 'stand', 180, true);
|
|
52
|
+
const sourcePath = local(spec.source, 'source', base);
|
|
53
|
+
const brandPath = local(spec.brand, 'brand', base);
|
|
54
|
+
const { bytes: brandBytes, value: brand } = await json(brandPath, 'brand');
|
|
55
|
+
for (const key of ['paper', 'ink', 'dim', 'accent']) {
|
|
56
|
+
if (!/^#[\da-f]{6}$/i.test(brand?.colors?.[key] ?? '')) throw fail('ART_BRAND_INVALID', `brand.colors.${key} must be a six-digit hex color`);
|
|
57
|
+
}
|
|
58
|
+
for (const key of ['display_stack', 'serif_stack', 'mono_stack']) {
|
|
59
|
+
const value = brand?.fonts?.[key];
|
|
60
|
+
if (typeof value !== 'string' || !/^[a-z\d ,"'_-]{1,500}$/i.test(value)) throw fail('ART_BRAND_INVALID', `brand.fonts.${key} must be a safe local font stack`);
|
|
61
|
+
}
|
|
62
|
+
const stamp = string(brand?.identity?.stamp, 'brand.identity.stamp', 12);
|
|
63
|
+
const name = string(brand?.identity?.name, 'brand.identity.name', 80);
|
|
64
|
+
const sourceBytes = await readFile(sourcePath);
|
|
65
|
+
let metadata, raster;
|
|
66
|
+
try {
|
|
67
|
+
metadata = await sharp(sourceBytes, { animated: true, limitInputPixels: 40_000_000 }).metadata();
|
|
68
|
+
if (!['png', 'jpeg', 'webp'].includes(metadata.format) || (metadata.pages ?? 1) !== 1) throw Error('Only single-frame PNG, JPEG, or WebP artwork is supported');
|
|
69
|
+
raster = await sharp(sourceBytes, { failOn: 'warning', limitInputPixels: 40_000_000 }).rotate().toColourspace('srgb').png({ palette: false }).toBuffer();
|
|
70
|
+
} catch (e) { throw fail('ART_SOURCE_INVALID', `Cannot decode source artwork: ${e.message}`); }
|
|
71
|
+
let fontCss = '', fontInfo = { mode: 'system-stacks', hostDependent: true, stacks: brand.fonts };
|
|
72
|
+
let display = brand.fonts.display_stack;
|
|
73
|
+
if (spec.fontPath !== undefined) {
|
|
74
|
+
const fontPath = local(spec.fontPath, 'fontPath', base);
|
|
75
|
+
const fontBytes = await readFile(fontPath);
|
|
76
|
+
if (!fontBytes.length || fontBytes.length > 10_000_000) throw fail('ART_FONT_INVALID', 'Local font must contain 1 to 10000000 bytes');
|
|
77
|
+
fontCss = `@font-face{font-family:ArtCoverDisplay;src:url(data:font/ttf;base64,${fontBytes.toString('base64')})}`;
|
|
78
|
+
display = `'ArtCoverDisplay', ${display}`;
|
|
79
|
+
fontInfo = { mode: 'embedded-display', path: fontPath, sha256: hash(fontBytes), hostDependent: true, fallback: 'Display glyph fallback and serif/mono stacks remain host dependent', stacks: brand.fonts };
|
|
80
|
+
}
|
|
81
|
+
const c = brand.colors;
|
|
82
|
+
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; font-src data:; style-src 'unsafe-inline'"><title>${escape(title)}</title><style>
|
|
83
|
+
${fontCss}
|
|
84
|
+
*{box-sizing:border-box}html,body{margin:0;width:1600px;height:900px;background:${c.paper};color:${c.ink}}
|
|
85
|
+
main{position:relative;width:1600px;height:900px;padding:54px 64px;overflow:hidden}
|
|
86
|
+
header{border-top:8px solid ${c.ink};display:flex;align-items:center;gap:20px;padding-top:18px}
|
|
87
|
+
.stamp{font:900 25px ${display};border:3px solid ${c.accent};padding:8px;transform:rotate(-4deg)}
|
|
88
|
+
.kicker,.name,footer{font:16px ${brand.fonts.mono_stack}}.name{margin-left:auto;color:${c.dim}}.kicker{max-width:820px}
|
|
89
|
+
h1{position:absolute;left:64px;top:203px;width:495px;margin:0;font:900 70px/1.02 ${display};letter-spacing:-.03em;overflow-wrap:anywhere}
|
|
90
|
+
.art{position:absolute;left:580px;top:196px;width:970px;height:610px;object-fit:contain}
|
|
91
|
+
.stand{position:absolute;left:66px;top:675px;width:465px;max-height:128px;margin:0;font:italic 26px/1.3 ${brand.fonts.serif_stack};color:${c.dim}}
|
|
92
|
+
footer{position:absolute;left:64px;right:64px;bottom:35px;border-top:2px solid ${c.ink};padding-top:16px;color:${c.dim}}
|
|
93
|
+
</style></head><body><main><header><div class="stamp">${escape(stamp)}</div><div class="kicker">${escape(kicker)}</div><div class="name">${escape(name)}</div></header><h1>${escape(title)}</h1><img class="art" alt="" src="data:image/png;base64,${raster.toString('base64')}"><p class="stand">${escape(stand)}</p><footer>IMPLEMENT IT IN YOUR PROJECT</footer></main></body></html>`;
|
|
94
|
+
// Exclusive mkdir establishes ownership; no recursive mkdir and no deletion on errors.
|
|
95
|
+
try { await mkdir(destination); }
|
|
96
|
+
catch (e) { throw fail(e.code === 'EEXIST' ? 'ART_OUTPUT_EXISTS' : 'ART_OUTPUT_INVALID', `Output must be a new directory with an existing parent: ${destination} (${e.message})`); }
|
|
97
|
+
let browser, imageBytes, geometry;
|
|
98
|
+
try {
|
|
99
|
+
browser = await chromium.launch({ headless: true, timeout: 15000 });
|
|
100
|
+
const page = await browser.newPage({ viewport: { width: 1600, height: 900 }, deviceScaleFactor: 1, serviceWorkers: 'block' });
|
|
101
|
+
await page.route('**/*', route => route.abort());
|
|
102
|
+
await page.setContent(html, { waitUntil: 'load', timeout: 15000 });
|
|
103
|
+
geometry = await page.evaluate(async hasFont => {
|
|
104
|
+
await Promise.race([
|
|
105
|
+
(async () => {
|
|
106
|
+
if (hasFont) {
|
|
107
|
+
try { if (!(await document.fonts.load('70px ArtCoverDisplay')).length) throw Error('No matching face'); }
|
|
108
|
+
catch { throw Error('Embedded display font failed to load'); }
|
|
109
|
+
}
|
|
110
|
+
await document.fonts.ready;
|
|
111
|
+
await Promise.all([...document.images].map(image => image.decode()));
|
|
112
|
+
})(),
|
|
113
|
+
new Promise((_, reject) => setTimeout(() => reject(Error('Font/image readiness timed out')), 10000)),
|
|
114
|
+
]);
|
|
115
|
+
if (document.querySelector('header').getBoundingClientRect().bottom > 176) throw Error('Header overflows into the title/art area; shorten the kicker or identity');
|
|
116
|
+
const heading = document.querySelector('h1');
|
|
117
|
+
let size = 70;
|
|
118
|
+
while (heading.getBoundingClientRect().bottom > 645 && size > 54) { size -= 2; heading.style.fontSize = `${size}px`; }
|
|
119
|
+
if (heading.getBoundingClientRect().bottom > 645) throw Error('Title overflows at minimum 54px; shorten it');
|
|
120
|
+
const bounds = {};
|
|
121
|
+
for (const selector of ['h1', '.art', '.stand', '.stamp', '.kicker', '.name', 'footer']) {
|
|
122
|
+
const el = document.querySelector(selector), r = el.getBoundingClientRect();
|
|
123
|
+
if (r.left < 0 || r.top < 0 || r.right > 1600 || r.bottom > 900 || el.scrollWidth > el.clientWidth + 1 || (selector === '.stand' && el.scrollHeight > el.clientHeight + 1)) throw Error(`Text or art overflows: ${selector}`);
|
|
124
|
+
bounds[selector] = { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
125
|
+
}
|
|
126
|
+
return { titleFontSize: size, bounds };
|
|
127
|
+
}, Boolean(spec.fontPath));
|
|
128
|
+
imageBytes = await page.screenshot({ type: 'png', timeout: 15000 });
|
|
129
|
+
} catch (e) { throw fail('ART_RENDER_FAILED', `Offline cover render failed: ${e.message}`); }
|
|
130
|
+
finally { if (browser) await browser.close(); }
|
|
131
|
+
const cover = await sharp(imageBytes).removeAlpha().toColourspace('srgb').png({ palette: false, compressionLevel: 9 }).toBuffer();
|
|
132
|
+
const thumbnail = await sharp(cover).resize(320, 180).png({ palette: false }).toBuffer();
|
|
133
|
+
const artifacts = { 'composition.html': Buffer.from(html), 'cover.png': cover, 'thumbnail.png': thumbnail };
|
|
134
|
+
for (const [file, bytes] of Object.entries(artifacts)) await writeFile(path.join(destination, file), bytes, { flag: 'wx' });
|
|
135
|
+
const result = {
|
|
136
|
+
schema: 1, renderer: 'offline-raster-art-v1', visualReview: 'pending', outputDir: destination,
|
|
137
|
+
inputs: { spec: { path: resolvedSpec, sha256: hash(specBytes) }, source: { path: sourcePath, sha256: hash(sourceBytes), format: metadata.format, width: metadata.width, height: metadata.height }, brand: { path: brandPath, sha256: hash(brandBytes) }, font: fontInfo },
|
|
138
|
+
title, geometry, width: 1600, height: 900, palette: false,
|
|
139
|
+
outputs: Object.fromEntries(Object.entries(artifacts).map(([file, bytes]) => [file, { sha256: hash(bytes), bytes: bytes.length }])),
|
|
140
|
+
};
|
|
141
|
+
// Commit marker written last. Absence means this attempt did not complete.
|
|
142
|
+
await writeFile(path.join(destination, 'result.json'), JSON.stringify(result, null, 2) + '\n', { flag: 'wx' });
|
|
143
|
+
return result;
|
|
144
|
+
}
|
package/lib/config_ops.mjs
CHANGED
|
@@ -29,6 +29,7 @@ export function removeProject(config, key) {
|
|
|
29
29
|
// Fields settable via `devlog set <field> <value>`. Everything funnels through
|
|
30
30
|
// validateConfig, so a bad value can never be persisted.
|
|
31
31
|
const SETTERS = {
|
|
32
|
+
generationMode: (c, v) => ({ ...c, generationMode: v }),
|
|
32
33
|
targetRepo: (c, v) => ({ ...c, targetRepo: v }),
|
|
33
34
|
branch: (c, v) => ({ ...c, branch: v }),
|
|
34
35
|
targetDir: (c, v) => (v === '' ? omit(c, 'targetDir') : { ...c, targetDir: v }),
|
package/lib/core.mjs
CHANGED
|
@@ -150,6 +150,9 @@ export function validateConfig(config) {
|
|
|
150
150
|
throw new Error(`voicePath must be a path with no shell metacharacters and no leading dash: got ${JSON.stringify(config.voicePath)}`);
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
|
+
if ('generationMode' in config && !['release', 'concept'].includes(config.generationMode)) {
|
|
154
|
+
throw new Error('generationMode must be release or concept');
|
|
155
|
+
}
|
|
153
156
|
if ('deepDive' in config) {
|
|
154
157
|
const d = config.deepDive;
|
|
155
158
|
if (!d || typeof d !== 'object' || Array.isArray(d)) throw new Error('deepDive must be an object');
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Additive, local-only guide preparation. Never executes article code or publishes.
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
7
|
+
import ReactMarkdown from 'react-markdown';
|
|
8
|
+
import remarkGfm from 'remark-gfm';
|
|
9
|
+
import sharp from 'sharp';
|
|
10
|
+
import { lintPost, parseFrontmatter } from './lint_post.mjs';
|
|
11
|
+
|
|
12
|
+
const START = '<!-- agent-handoff:start -->';
|
|
13
|
+
const END = '<!-- agent-handoff:end -->';
|
|
14
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
15
|
+
const escape = value => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
16
|
+
const fail = (code, message, findings) => Object.assign(new Error(message), { code, ...(findings ? { findings } : {}) });
|
|
17
|
+
|
|
18
|
+
function handoff(markdown) {
|
|
19
|
+
const findings = [];
|
|
20
|
+
const add = (rule, message) => findings.push({ rule, message });
|
|
21
|
+
if (markdown.split(START).length !== 2 || markdown.split(END).length !== 2) {
|
|
22
|
+
add('guide-handoff-markers', 'Use exactly one agent-handoff:start and one agent-handoff:end marker.');
|
|
23
|
+
return { findings, reference: markdown };
|
|
24
|
+
}
|
|
25
|
+
const start = markdown.indexOf(START);
|
|
26
|
+
const end = markdown.indexOf(END);
|
|
27
|
+
if (end <= start) {
|
|
28
|
+
add('guide-handoff-order', 'The handoff end marker must follow its start marker.');
|
|
29
|
+
return { findings, reference: markdown };
|
|
30
|
+
}
|
|
31
|
+
const { body } = parseFrontmatter(markdown);
|
|
32
|
+
if (!body.trimStart().startsWith(START)) add('guide-handoff-position', 'The handoff must be the first body content, before the introduction and Shipped.');
|
|
33
|
+
const block = markdown.slice(start + START.length, end);
|
|
34
|
+
const fences = [...block.replaceAll('\r\n', '\n').matchAll(/^```([^\n]*)$/gm)];
|
|
35
|
+
const match = /^```text\r?\n([\s\S]*?)\r?\n```\s*$/m.exec(block);
|
|
36
|
+
if (fences.length !== 2 || fences[0]?.[1] !== 'text' || fences[1]?.[1] !== '' || !match || !match[1].trim()) {
|
|
37
|
+
add('guide-handoff-prompt', 'The handoff must contain exactly one nonempty fenced text prompt.');
|
|
38
|
+
}
|
|
39
|
+
return { findings, prompt: match?.[1], reference: markdown.slice(0, start) + markdown.slice(end + END.length) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Same result shape as lintPost. Structural checks do not prove semantic quality or review.
|
|
43
|
+
export function lintGuide(markdown, { voice = false } = {}) {
|
|
44
|
+
const parsed = handoff(markdown);
|
|
45
|
+
const findings = [...lintPost(parsed.reference.replaceAll('\r\n', '\n'), { voice }).findings, ...parsed.findings];
|
|
46
|
+
return { ok: findings.length === 0, findings };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function brandCss(tokens) {
|
|
50
|
+
const fields = { paper: tokens.colors?.paper, ink: tokens.colors?.ink, dim: tokens.colors?.dim, accent: tokens.colors?.accent, hair: tokens.derived?.hair ?? tokens.colors?.ink };
|
|
51
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
52
|
+
if (typeof value !== 'string' || !/^(#[a-f\d]{6}|rgba\(\s*\d{1,3},\s*\d{1,3},\s*\d{1,3},\s*(?:0|1|0?\.\d+)\s*\))$/i.test(value)) throw fail('GUIDE_BRAND_INVALID', `Invalid PRESS color: ${name}`);
|
|
53
|
+
}
|
|
54
|
+
for (const name of ['display', 'mono', 'serif']) {
|
|
55
|
+
const value = tokens.fonts?.[`${name}_stack`];
|
|
56
|
+
if (typeof value !== 'string' || !value.trim() || !/^[a-z\d\s,'"-]+$/i.test(value)) throw fail('GUIDE_BRAND_INVALID', `Invalid PRESS font stack: ${name}`);
|
|
57
|
+
fields[`font-${name}`] = value;
|
|
58
|
+
}
|
|
59
|
+
for (const name of ['stamp', 'name']) if (typeof tokens.identity?.[name] !== 'string' || !tokens.identity[name].trim()) throw fail('GUIDE_BRAND_INVALID', `Missing PRESS identity: ${name}`);
|
|
60
|
+
return `:root{${Object.entries(fields).map(([key, value]) => `--${key}:${value}`).join(';')}}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function prepareGuidePreview({ articlePath, outDir, brandPath, coverPath } = {}) {
|
|
64
|
+
for (const [name, value] of Object.entries({ articlePath, outDir, brandPath })) if (typeof value !== 'string' || !value.trim()) throw fail('GUIDE_ARGUMENT', `${name} is required.`);
|
|
65
|
+
const markdown = await fs.readFile(articlePath, 'utf8');
|
|
66
|
+
const lint = lintGuide(markdown);
|
|
67
|
+
if (!lint.ok) throw fail('GUIDE_LINT', 'Guide failed structural lint.', lint.findings);
|
|
68
|
+
const { prompt, reference } = handoff(markdown);
|
|
69
|
+
const payload = `${prompt}\n\n<reference-guide>\n${reference.trim()}\n</reference-guide>\n`;
|
|
70
|
+
const { data } = parseFrontmatter(markdown.replaceAll('\r\n', '\n'));
|
|
71
|
+
const { body } = parseFrontmatter(reference);
|
|
72
|
+
const brandRaw = await fs.readFile(brandPath, 'utf8');
|
|
73
|
+
let tokens;
|
|
74
|
+
try { tokens = JSON.parse(brandRaw); } catch { throw fail('GUIDE_BRAND_INVALID', 'PRESS brand file must contain JSON.'); }
|
|
75
|
+
if (!tokens || typeof tokens !== 'object') throw fail('GUIDE_BRAND_INVALID', 'PRESS brand file must contain an object.');
|
|
76
|
+
const css = brandCss(tokens);
|
|
77
|
+
let cover;
|
|
78
|
+
if (coverPath) {
|
|
79
|
+
cover = await fs.readFile(coverPath);
|
|
80
|
+
try {
|
|
81
|
+
const decoder = sharp(cover, { limitInputPixels: 40_000_000 });
|
|
82
|
+
const meta = await decoder.metadata();
|
|
83
|
+
if (meta.format !== 'png' || meta.width !== 1600 || meta.height !== 900 || (meta.pages || 1) !== 1) throw new Error('Expected one 1600 × 900 PNG.');
|
|
84
|
+
await decoder.raw().toBuffer();
|
|
85
|
+
} catch (error) { throw fail('GUIDE_COVER_INVALID', `Cover must be a decodable, single-frame 1600 × 900 PNG: ${error.message}`); }
|
|
86
|
+
}
|
|
87
|
+
const styles = await fs.readFile(new URL('./guide_preview.css', import.meta.url), 'utf8');
|
|
88
|
+
const rendered = renderToStaticMarkup(React.createElement(ReactMarkdown, {
|
|
89
|
+
remarkPlugins: [remarkGfm], skipHtml: true,
|
|
90
|
+
components: {
|
|
91
|
+
// Validate resolved Markdown nodes, including reference-style links. This
|
|
92
|
+
// draft helper copies one article, not an arbitrary companion directory.
|
|
93
|
+
a({ node: _node, href, children, ...props }) {
|
|
94
|
+
if (typeof href !== 'string' || !/^(https?:\/\/|#)/i.test(href)) throw fail('GUIDE_ASSET_UNSUPPORTED', 'Guide links must use public HTTP(S) URLs or same-page anchors. Relative companion files are not bundled; include required code inline.');
|
|
95
|
+
return React.createElement('a', { ...props, href }, children);
|
|
96
|
+
},
|
|
97
|
+
img() { throw fail('GUIDE_ASSET_UNSUPPORTED', 'Embedded Markdown images are not bundled. Supply a local PNG with coverPath, or retain the draft until companion-asset support is available.'); },
|
|
98
|
+
},
|
|
99
|
+
}, body));
|
|
100
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(data.title)}</title><style>${css}\n${styles}</style></head><body><main>
|
|
101
|
+
<header class="masthead"><div class="stamp">${escape(tokens.identity.stamp)}</div><div class="eyebrow">CONCEPT GUIDE · LOCAL DRAFT</div><div class="byline">${escape(tokens.identity.name)}</div></header>
|
|
102
|
+
<h1>${escape(data.title)}</h1><section class="agent-handoff" aria-labelledby="agent-heading"><h2 id="agent-heading">Implement this with your agent</h2><p>Copy the implementation prompt and complete guide, then paste into your coding agent in your project.</p><button type="button" id="copy-agent">Copy prompt + guide</button><p id="copy-status" role="status" aria-live="polite"></p><textarea id="copy-fallback" readonly hidden aria-label="Prompt and complete guide for manual copying"></textarea><details><summary>Read the prompt</summary><pre>${escape(prompt)}</pre></details><p><a href="agent-prompt.txt" download>Download prompt + complete guide</a></p><noscript><p>JavaScript is disabled. Open the <a href="agent-prompt.txt">complete plain-text handoff</a> and copy it into your agent.</p></noscript></section>
|
|
103
|
+
${cover ? `<figure><a href="cover.png"><img src="cover.png" width="1600" height="900" alt="Cover for ${escape(data.title)}"></a></figure>` : ''}
|
|
104
|
+
<p class="notice">Draft for review · <a href="article.md">Markdown guide</a> · Structural lint passed; implementation and independent review are separate checks.</p>${rendered}<footer class="colophon">Local draft. This helper does not publish.</footer></main>
|
|
105
|
+
<script type="application/json" id="agent-payload">${JSON.stringify(payload).replaceAll('<', '\\u003c')}</script>
|
|
106
|
+
<script>
|
|
107
|
+
const payload = JSON.parse(document.getElementById('agent-payload').textContent);
|
|
108
|
+
const button = document.getElementById('copy-agent');
|
|
109
|
+
const status = document.getElementById('copy-status');
|
|
110
|
+
const fallback = document.getElementById('copy-fallback');
|
|
111
|
+
button.addEventListener('click', async () => {
|
|
112
|
+
status.textContent = '';
|
|
113
|
+
try {
|
|
114
|
+
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
|
|
115
|
+
await navigator.clipboard.writeText(payload);
|
|
116
|
+
fallback.hidden = true;
|
|
117
|
+
status.textContent = 'Copied prompt and complete guide. Paste into your agent.';
|
|
118
|
+
} catch {
|
|
119
|
+
fallback.hidden = false; fallback.value = payload; fallback.focus(); fallback.select();
|
|
120
|
+
status.textContent = 'Automatic copy is unavailable. The full text is selected below; press Cmd+C or Ctrl+C.';
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
</script></body></html>`;
|
|
124
|
+
const result = { schemaVersion: 1, status: 'local-draft', article: 'article.md', preview: 'index.html', agentPrompt: 'agent-prompt.txt', cover: cover ? 'cover.png' : null, articleSha256: hash(markdown), previewSha256: hash(html), stylesheetSha256: hash(styles), agentPromptSha256: hash(payload), brandSha256: hash(brandRaw), ...(cover ? { coverSha256: hash(cover) } : {}), lint, verification: { implementation: 'not-run', independentReview: 'not-run' } };
|
|
125
|
+
// mkdir without recursive is the exclusive claim. Existing directories/symlinks fail;
|
|
126
|
+
// a failed partial write retains no result.json completion marker and is never reused.
|
|
127
|
+
await fs.mkdir(outDir);
|
|
128
|
+
await fs.writeFile(path.join(outDir, 'article.md'), markdown, { flag: 'wx' });
|
|
129
|
+
await fs.writeFile(path.join(outDir, 'index.html'), html, { flag: 'wx' });
|
|
130
|
+
await fs.writeFile(path.join(outDir, 'agent-prompt.txt'), payload, { flag: 'wx' });
|
|
131
|
+
if (cover) await fs.writeFile(path.join(outDir, 'cover.png'), cover, { flag: 'wx' });
|
|
132
|
+
await fs.writeFile(path.join(outDir, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx' });
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--font-display);font-size:17px;line-height:1.65}main{max-width:1000px;margin:auto;padding:44px 40px 72px}header.masthead{border-top:8px solid var(--ink);padding-top:18px;display:flex;gap:20px;align-items:center;margin-bottom:42px}.stamp{font-family:var(--font-display);font-weight:900;font-size:22px;border:3px solid var(--accent);color:var(--accent);padding:7px;transform:rotate(-4deg)}.eyebrow,.byline,.colophon{font-family:var(--font-mono);font-size:12px;letter-spacing:.04em}.byline{margin-left:auto;color:var(--dim)}h1{font-size:clamp(38px,6vw,64px);line-height:1.05;letter-spacing:-.03em;font-weight:900;margin:0 0 32px;max-width:850px}h2{font-size:27px;line-height:1.2;border-top:2px solid var(--ink);padding-top:24px;margin-top:48px;letter-spacing:-.02em}p{max-width:780px}a{color:inherit;text-underline-offset:3px}pre{font-family:var(--font-mono);font-size:13px;line-height:1.6;overflow-x:auto;padding:20px 0;border-block:1px solid var(--hair)}code{font-family:var(--font-mono);font-size:.88em}pre code{font-size:inherit}img{display:block;max-width:100%;height:auto;margin:24px auto}table{border-collapse:collapse;width:100%;font-size:14px}td,th{border-bottom:1px solid var(--hair);padding:12px 16px 12px 0;text-align:left;vertical-align:top}th{border-bottom:2px solid var(--ink)}.notice{font-family:var(--font-mono);font-size:13px;color:var(--dim);padding-bottom:22px;border-bottom:1px solid var(--hair);margin-bottom:32px}.colophon{border-top:2px solid var(--ink);margin-top:48px;padding-top:18px;color:var(--dim)}@media(max-width:600px){main{padding:24px 20px}.byline{display:none}table{font-size:12px}}@media print{main{padding:0;max-width:none}pre{white-space:pre-wrap}h2{break-after:avoid}img{break-inside:avoid}}
|
|
2
|
+
.agent-handoff{border-block:2px solid var(--ink);padding:22px 0;margin:0 0 32px}.agent-handoff h2{border:0;margin:0;padding:0}.agent-handoff pre{white-space:pre-wrap;overflow-wrap:anywhere}button{font:700 14px var(--font-display);background:var(--ink);color:var(--paper);border:2px solid var(--ink);padding:12px 18px;cursor:pointer}button:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}summary{cursor:pointer;font-weight:700;margin-top:16px}#copy-status{font-family:var(--font-mono);font-size:12px;min-height:1.5em}#copy-fallback{width:100%;height:240px;margin-top:12px;background:var(--paper);color:var(--ink);font:13px var(--font-mono);border:1px solid var(--ink);padding:12px}figure{margin:32px 0}td,th{overflow-wrap:anywhere}@media print{button,#copy-status,#copy-fallback{display:none}}
|
|
3
|
+
main{overflow-wrap:anywhere;min-width:0}table{display:block;overflow-x:auto}header.masthead{flex-wrap:wrap}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Strict, additive guide publication. Receipts establish local consistency, not
|
|
2
|
+
// independent proof that commands ran or that an author reviewed the result.
|
|
3
|
+
import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync, lstatSync } from 'node:fs';
|
|
4
|
+
import { resolve, dirname, join, isAbsolute } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import sharp from 'sharp';
|
|
8
|
+
import { lintGuide } from './guide_draft.mjs';
|
|
9
|
+
import { parseFrontmatter } from './lint_post.mjs';
|
|
10
|
+
import { publishEntry } from './publish_entry.mjs';
|
|
11
|
+
import { RE_PROJECT_KEY, RE_FINAL_RELEASE } from './core.mjs';
|
|
12
|
+
|
|
13
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
14
|
+
const fail = message => Object.assign(new Error(message), { code: 'GUIDE_EVIDENCE_INVALID' });
|
|
15
|
+
function requireThat(condition, message) { if (!condition) throw fail(message); }
|
|
16
|
+
function text(value) { return typeof value === 'string' && value.trim().length > 0; }
|
|
17
|
+
function local(base, value) {
|
|
18
|
+
requireThat(text(value) && (isAbsolute(value) || !/^[a-z][a-z\d+.-]*:/i.test(value)), 'Evidence paths must be local filesystem paths');
|
|
19
|
+
return resolve(base, value);
|
|
20
|
+
}
|
|
21
|
+
function parse(bytes, label) {
|
|
22
|
+
try { const data = JSON.parse(bytes.toString('utf8')); requireThat(data && typeof data === 'object' && !Array.isArray(data), `${label} must be a JSON object`); return data; }
|
|
23
|
+
catch (error) { if (error.code === 'GUIDE_EVIDENCE_INVALID') throw error; throw fail(`${label} must contain valid JSON`); }
|
|
24
|
+
}
|
|
25
|
+
function checkedFile(base, ref, label) {
|
|
26
|
+
requireThat(ref && /^[a-f\d]{64}$/.test(ref.sha256 ?? ''), `${label} requires a SHA-256`);
|
|
27
|
+
const path = local(base, ref.path);
|
|
28
|
+
const bytes = readFileSync(path);
|
|
29
|
+
requireThat(digest(bytes) === ref.sha256, `${label} hash is stale`);
|
|
30
|
+
return { path, bytes };
|
|
31
|
+
}
|
|
32
|
+
function report(base, ref, label, articleSha256, agentPromptSha256) {
|
|
33
|
+
const file = checkedFile(base, ref, label);
|
|
34
|
+
const value = parse(file.bytes, label);
|
|
35
|
+
requireThat(value.status === 'passed' && text(value.summary), `${label} must pass with observed summary`);
|
|
36
|
+
requireThat(value.articleSha256 === articleSha256 && value.agentPromptSha256 === agentPromptSha256, `${label} is bound to different article or agent payload bytes`);
|
|
37
|
+
return { value, base: dirname(file.path) };
|
|
38
|
+
}
|
|
39
|
+
function present(file) {
|
|
40
|
+
try { lstatSync(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
41
|
+
}
|
|
42
|
+
function assertVacant(cloneDir, project, version) {
|
|
43
|
+
requireThat(existsSync(cloneDir), 'Clone directory does not exist');
|
|
44
|
+
const projectDir = join(cloneDir, project);
|
|
45
|
+
if (present(projectDir)) requireThat(lstatSync(projectDir).isDirectory() && !lstatSync(projectDir).isSymbolicLink(), 'Project directory must be an ordinary directory');
|
|
46
|
+
for (const suffix of ['md', 'png']) {
|
|
47
|
+
requireThat(!present(join(projectDir, `${version}.${suffix}`)), `Guide identity ${project}/${version} is occupied (${suffix}); explicit editorial rewrite is separate`);
|
|
48
|
+
}
|
|
49
|
+
const manifestPath = join(projectDir, 'manifest.json');
|
|
50
|
+
if (existsSync(manifestPath)) {
|
|
51
|
+
const manifest = parse(readFileSync(manifestPath), 'Manifest');
|
|
52
|
+
requireThat(Array.isArray(manifest.entries), 'Manifest must contain entries');
|
|
53
|
+
requireThat(!manifest.entries.some(entry => entry && (entry.version === version || entry.file === `${version}.md`)), `Guide identity ${project}/${version} is occupied or tombstoned in the manifest`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Local preflight followed by existing publishEntry; never commits or pushes.
|
|
58
|
+
* All evidence paths resolve relative to evidencePath; output-log paths resolve
|
|
59
|
+
* relative to their execution report. Missing config mode does not call this API.
|
|
60
|
+
*/
|
|
61
|
+
export async function publishGuide({ cloneDir, articlePath, evidencePath, coverPath } = {}) {
|
|
62
|
+
try {
|
|
63
|
+
requireThat(text(cloneDir) && text(articlePath) && text(evidencePath), 'cloneDir, articlePath and evidencePath are required');
|
|
64
|
+
const articleBytes = readFileSync(articlePath);
|
|
65
|
+
const markdown = articleBytes.toString('utf8');
|
|
66
|
+
const lint = lintGuide(markdown);
|
|
67
|
+
requireThat(lint.ok, `Guide lint failed: ${lint.findings.map(item => item.rule).join(', ')}`);
|
|
68
|
+
const evidence = parse(readFileSync(evidencePath), 'Evidence');
|
|
69
|
+
const base = dirname(resolve(evidencePath));
|
|
70
|
+
requireThat(evidence.schema === 1, 'Evidence schema must be 1');
|
|
71
|
+
const articleSha256 = digest(articleBytes);
|
|
72
|
+
requireThat(evidence.articleSha256 === articleSha256, 'Article hash is stale');
|
|
73
|
+
const { data } = parseFrontmatter(markdown.replaceAll('\r\n', '\n'));
|
|
74
|
+
const anchor = evidence.anchor;
|
|
75
|
+
requireThat(anchor && typeof anchor.project === 'string' && RE_PROJECT_KEY.test(anchor.project) && !anchor.project.includes('..'), 'Anchor project is invalid');
|
|
76
|
+
requireThat(typeof anchor.version === 'string' && RE_FINAL_RELEASE.test(anchor.version), 'Anchor must identify a final release version');
|
|
77
|
+
requireThat(typeof anchor.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(anchor.date) && new Date(anchor.date).toISOString().slice(0, 10) === anchor.date, 'Anchor date must be a valid ISO date');
|
|
78
|
+
requireThat(data.project === anchor.project && data.version === anchor.version && String(data.date) === anchor.date, 'Article project/version/date must match the selected anchor');
|
|
79
|
+
const start = '<!-- agent-handoff:start -->', end = '<!-- agent-handoff:end -->';
|
|
80
|
+
const startIndex = markdown.indexOf(start), endIndex = markdown.indexOf(end);
|
|
81
|
+
const block = markdown.slice(startIndex + start.length, endIndex);
|
|
82
|
+
const prompt = /^```text\r?\n([\s\S]*?)\r?\n```\s*$/m.exec(block)[1];
|
|
83
|
+
const reference = markdown.slice(0, startIndex) + markdown.slice(endIndex + end.length);
|
|
84
|
+
const payload = `${prompt}\n\n<reference-guide>\n${reference.trim()}\n</reference-guide>\n`;
|
|
85
|
+
const payloadFile = checkedFile(base, evidence.agentPrompt, 'Agent prompt');
|
|
86
|
+
requireThat(payloadFile.bytes.equals(Buffer.from(payload)), 'Agent prompt does not match the exact handoff/reference payload');
|
|
87
|
+
const agentPromptSha256 = digest(payloadFile.bytes);
|
|
88
|
+
const execution = report(base, evidence.execution, 'Execution report', articleSha256, agentPromptSha256);
|
|
89
|
+
requireThat(Array.isArray(execution.value.commands) && execution.value.commands.length > 0, 'Execution report needs command observations');
|
|
90
|
+
for (const command of execution.value.commands) {
|
|
91
|
+
requireThat(command && text(command.command) && command.exitCode === 0, 'Every execution command must record successful observed completion');
|
|
92
|
+
const output = checkedFile(execution.base, command.output, 'Execution output');
|
|
93
|
+
requireThat(output.bytes.length > 0, 'Execution output cannot be empty');
|
|
94
|
+
}
|
|
95
|
+
const adaptation = report(base, evidence.adaptation, 'Adaptation report', articleSha256, agentPromptSha256).value;
|
|
96
|
+
requireThat(text(adaptation.fixture?.original) && text(adaptation.fixture?.result), 'Adaptation report must identify original and resulting fixtures');
|
|
97
|
+
requireThat(Array.isArray(adaptation.independentChecks) && adaptation.independentChecks.length > 0 && adaptation.independentChecks.every(check => check && text(check.check) && text(check.observation) && check.passed === true), 'Adaptation report needs passing independent checks with observations');
|
|
98
|
+
const review = report(base, evidence.review, 'Review report', articleSha256, agentPromptSha256).value;
|
|
99
|
+
requireThat(text(review.reviewer) && Array.isArray(review.blockingFindings) && review.blockingFindings.length === 0, 'Review report needs a reviewer and no unresolved blocking findings');
|
|
100
|
+
let coverImageBuffer;
|
|
101
|
+
if (coverPath !== undefined) {
|
|
102
|
+
requireThat(text(coverPath), 'coverPath must be a local file');
|
|
103
|
+
coverImageBuffer = readFileSync(coverPath);
|
|
104
|
+
const coverSha256 = digest(coverImageBuffer);
|
|
105
|
+
requireThat(evidence.coverSha256 === coverSha256 && review.coverSha256 === coverSha256 && text(review.coverReview), 'Cover bytes require matching evidence/review hashes and visual observations');
|
|
106
|
+
const image = sharp(coverImageBuffer, { animated: true, limitInputPixels: 40_000_000 });
|
|
107
|
+
const metadata = await image.metadata();
|
|
108
|
+
requireThat(metadata.format === 'png' && metadata.width === 1600 && metadata.height === 900 && (metadata.pages ?? 1) === 1 && !metadata.isPalette, 'Cover must be a single-frame true-color 1600x900 PNG');
|
|
109
|
+
await image.raw().toBuffer();
|
|
110
|
+
} else requireThat(evidence.coverSha256 === undefined && review.coverSha256 === undefined, 'Evidence references a cover but no coverPath was provided');
|
|
111
|
+
// Final check after async decoding, before any content write. A frozen local
|
|
112
|
+
// snapshot prevents article edits between evidence validation and copyFile.
|
|
113
|
+
assertVacant(cloneDir, anchor.project, anchor.version);
|
|
114
|
+
const staging = mkdtempSync(join(tmpdir(), 'devlog-validated-guide-'));
|
|
115
|
+
try {
|
|
116
|
+
const entryPath = join(staging, 'article.md');
|
|
117
|
+
writeFileSync(entryPath, articleBytes, { flag: 'wx' });
|
|
118
|
+
return { ...publishEntry({ cloneDir, project: anchor.project, version: anchor.version, entryPath, coverImageBuffer }), evidenceValidated: true, articleSha256, agentPromptSha256 };
|
|
119
|
+
} finally { rmSync(staging, { recursive: true, force: true }); }
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error.code === 'GUIDE_EVIDENCE_INVALID') throw error;
|
|
122
|
+
throw fail(`Guide publication failed: ${error.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@natjswenson/devlog",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Release dev log generator
|
|
3
|
+
"version": "0.14.0",
|
|
4
|
+
"description": "Release dev log generator — 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",
|
|
7
7
|
"homepage": "https://github.com/natejswenson/devlog",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"examples/",
|
|
35
35
|
"voice/",
|
|
36
36
|
"image-style/",
|
|
37
|
+
"references/",
|
|
37
38
|
"SKILL.md",
|
|
38
39
|
"SECURITY.md",
|
|
39
40
|
"CHANGELOG.md",
|
|
@@ -59,7 +60,7 @@
|
|
|
59
60
|
"react-dom": "18.3.1",
|
|
60
61
|
"react-markdown": "9.1.0",
|
|
61
62
|
"remark-gfm": "4.0.1",
|
|
62
|
-
"sharp": "0.35.
|
|
63
|
+
"sharp": "0.35.4",
|
|
63
64
|
"vite": "8.0.16"
|
|
64
65
|
}
|
|
65
66
|
}
|