@natjswenson/devlog 0.13.0 → 0.14.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.
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
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.13.0",
4
- "description": "Release dev log generator \u2014 Claude Code skill + preview app for publishing version-release dev logs, written in your voice, to your site",
3
+ "version": "0.14.1",
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.3",
63
+ "sharp": "0.35.4",
63
64
  "vite": "8.0.16"
64
65
  }
65
66
  }
@@ -0,0 +1,119 @@
1
+ # AI artwork for a local cover draft
2
+
3
+ This path is selected explicitly for an AI cover draft, by Concept guide draft mode,
4
+ or for the single guide selected by an opted-in normal concept Generate run.
5
+ It never scans releases, publishes, replaces existing cover files or changes branding
6
+ configuration. The legacy Generate mode's local SVG/HTML renderer and failure behavior
7
+ are unchanged. On Claude, use that existing local workflow for concept-guide covers. For strict
8
+ concept publication, expand its palette PNG into a true-color PNG with the bundled
9
+ Sharp dependency before final inspection and hashing. This conversion does not restore
10
+ any detail already lost during quantization; retain the source and inspect the result.
11
+ Do not apply a second palette reduction to a native AI cover.
12
+
13
+ For Claude's existing palette output only, this local conversion uses the already
14
+ bundled dependency and refuses to overwrite the new output path:
15
+
16
+ ```bash
17
+ node --input-type=module - '<skill-root>/package.json' '<legacy-cover.png>' '<new-true-color-cover.png>' <<'JS'
18
+ import { createRequire } from 'node:module';
19
+ import { resolve } from 'node:path';
20
+ import { writeFile } from 'node:fs/promises';
21
+ const [packageFile, input, output] = process.argv.slice(2);
22
+ const sharp = createRequire(resolve(packageFile))('sharp');
23
+ const bytes = await sharp(input).toColourspace('srgb').png({ palette: false }).toBuffer();
24
+ await writeFile(output, bytes, { flag: 'wx' });
25
+ JS
26
+ ```
27
+
28
+ Inspect and hash that final output, not the original palette file. A Git-installed plugin or standalone SKILL.md may have no dependencies beside it.
29
+ In that case use the exact-version published package runtime described in SKILL.md;
30
+ resolve its actual package/dependency location for this conversion instead of writing
31
+ into an internal plugin cache or assuming node_modules exists. If unavailable, retain
32
+ the draft and report the missing conversion capability rather than guessing a tool
33
+ path. The normal Codex native-art compositor uses the same exact-version helper
34
+ fallback when bundled dependencies are absent.
35
+
36
+ ## Capability and brief
37
+
38
+ In Codex, inspect whether native image generation is actually available. Use the native image-generation tool for new art and native editing for raster changes
39
+ when present; no API key or image API client belongs in the Node helper. If
40
+ unavailable or failed, save the draft and art brief with the blocker. Do not silently
41
+ substitute SVG, claim an image exists, or activate a paid CLI/API fallback. An explicitly
42
+ requested fallback follows the available imagegen skill's instructions.
43
+
44
+ Finish the guide before choosing the visual. Read the reviewed outcome, mechanism,
45
+ failure case and exclusions, then make a short public-safe brief. Do not send the entire
46
+ guide handoff, private repository files, source logs or private identities to generation.
47
+ Inspect the most recent relevant cover if available. A reference guides craft, not a
48
+ copied subject; lack of a first reference is not a blocker.
49
+
50
+ Choose one concrete metaphor whose meaning matches the article. The proven direction
51
+ is detailed editorial engraving with controlled hatching and a clean silhouette, quiet
52
+ paper surrounding the subject and one small focal accent. Complexity must describe the
53
+ mechanism, not decorate it. A beautiful picture implying an unsupported guarantee fails.
54
+
55
+ Read palette and identity from adopted/generated brand resources, respecting custom
56
+ style restrictions. Do not duplicate brand values manually. Prefer an opaque paper
57
+ background: the pilot's transparency requests repeatedly returned painted checkerboards.
58
+ Use transparency only when needed, and inspect actual alpha plus the rendered composite.
59
+
60
+ ## Generate, persist and compose
61
+
62
+ Write a prompt specifying subject, mechanism, medium, framing, craft, supplied palette,
63
+ and exclusions. Request **artwork only**, with no lettering, labels, numbers, logos or
64
+ fake code. Typography is rendered locally. One strong candidate is enough; no mandatory
65
+ batch. Use only supported tool arguments, and record only runtime details actually
66
+ reported. Do not invent model, seed, quality or destination controls.
67
+
68
+ After the tool returns, copy that exact returned image to a versioned source path in
69
+ the run directory. Never pick the newest file in a shared image folder. Save the prompt,
70
+ brief, reference hashes and attributable tool result. Inspect the image before any
71
+ native edit; preserve explicit invariants. If bytes cannot be recovered, record that
72
+ blocker rather than inventing a file. Retain original sources when revising.
73
+
74
+ The deterministic compositor is separate from AI generation:
75
+
76
+ ```text
77
+ devlog compose-art-cover --spec /absolute/cover-spec.json --out /absolute/new-cover-attempt
78
+ ```
79
+
80
+ Use the schema documented in the helper's `cover-spec` reference below. Paths resolve
81
+ against the spec file, not the caller's working directory. No agent-authored executable
82
+ HTML is accepted. The helper decodes local art, embeds it in an owned layout, renders
83
+ escaped typography, preserves aspect ratio, exports a 1600×900 true-color PNG and
84
+ 320px thumbnail, and writes a result marker last. It blocks browser network requests.
85
+ System-font rendering is host-dependent and must be reported; supply an adopted local
86
+ font when exact font delivery is required. It never overwrites the source or an existing
87
+ attempt directory and never calls an image service or publication code.
88
+
89
+ ## Review and resume
90
+
91
+ Open the actual source and final cover, both full size and thumbnail. Read every
92
+ overlaid word. Check meaning, coherent detail, small-size silhouette, title contrast,
93
+ crop, accidental generated text, brand fit and distinction from nearby covers. Show
94
+ the final PNG and open the preview for the user. A hash or mechanical success cannot
95
+ certify visual meaning, quality or approval. Local preview is not proof of the live
96
+ site's feed crop or image loading.
97
+
98
+ Save `cover-art.json` locally with schema version 1, brief/prompt hashes, source and
99
+ reference hashes, selected composition/result, and observed review findings tied to
100
+ the exact PNG hash. Do not copy that private receipt into manifests or the reader's
101
+ handoff. Measure output bytes; do not apply the legacy palette reducer to detailed art.
102
+
103
+ Fix typography locally; fix illustration defects with native editing/generation.
104
+ Allow at most two targeted correction cycles after the first candidate. A critical
105
+ unresolved failure holds the cover and retains the completed guide. Each changed
106
+ source/brief needs meaning review; each changed crop/font/title/palette needs final
107
+ image review. Unrelated prose edits call for a brief check, not automatic regeneration.
108
+
109
+ An image without a completed result marker and matching hashes is an incomplete
110
+ attempt. Recover from recorded inputs; uncertain generation must not trigger unattended
111
+ retries. No cover is promoted merely because its filename is `cover.png`. After a
112
+ reviewed result, pass its PNG to `prepare-guide --cover`; the agent handoff stays first.
113
+ Explicit draft requests stop here. For an opted-in normal concept Generate run,
114
+ continue through guide-publishing.md and its actual consumer checks. Existing-post
115
+ backfill remains separately scoped work. Older readers can continue loading the final PNG.
116
+
117
+ ## Cover spec
118
+
119
+ See [cover-spec.md](cover-spec.md) for the exact schema and generated-brand input.
@@ -0,0 +1,148 @@
1
+ # One complete concept guide
2
+
3
+ Use for explicit concept-guide, agent-friendly guide, or consolidation requests. This
4
+ is the drafting and verification workflow on either host. Explicit draft requests
5
+ remain draft-only. A normal Generate request with the already selected
6
+ `generationMode: "concept"` uses these same quality steps, then completes
7
+ [guide-publishing.md](guide-publishing.md). Missing mode or `"release"` retains legacy
8
+ Generate. Never change the persistent preference merely because a draft was requested,
9
+ scan-and-publish every tag, or mark other releases covered. Existing publish-entry and
10
+ manifest formats remain unchanged.
11
+
12
+ ## Choose one outcome
13
+
14
+ Inspect the user's selected sources and relevant existing article bodies. Titles and
15
+ tags are discovery aids, not evidence of duplicate concepts. Releases supply evidence;
16
+ they do not determine article count. Select at most one complete reader outcome:
17
+
18
+ > A reader with [prerequisites] can implement [capability] in their own project,
19
+ > verify it through [observable result], and handle [important failure].
20
+
21
+ Keep useful independent outcomes separate even when they share tools. For existing
22
+ posts recommend keep, improve, consolidation review or historical, with body evidence.
23
+ Consolidation means a new local draft first, not deleting sources or repurposing their
24
+ URLs. Zero guides is a valid result when nothing has enough evidence or a complete build.
25
+
26
+ Record a short local brief: chosen outcome, applicability limits, source revisions,
27
+ overlap decision, smallest complete example, meaningful failure and verification plan.
28
+ Verify source facts against the actual revision and external technical claims against
29
+ primary documentation, following Generate's ground-truth and voice rules. Private
30
+ source identities, paths and logs stay out of public prose, art prompts and manifests.
31
+
32
+ ## Write a build a stranger can finish
33
+
34
+ Resolve voice using the entrypoint's voice rules. Keep the required `Shipped`, `Gotchas`
35
+ and `Sources` sections and existing flat frontmatter fields. Put a reader introduction
36
+ before the short Shipped origin note; distinguish the teaching example from shipped code.
37
+ Retain a genuine source release's project/version/date for draft metadata. For a
38
+ consolidation, metadata is provenance only: an occupied identity is not a destination.
39
+ Do not invent a release or silently change an existing date to make a guide publishable.
40
+
41
+ Provide the complete file tree, prerequisites, code, invocation and verification.
42
+ Essential adapters, provider calls and configuration are part of the build, not reader
43
+ homework. Frame offline examples honestly; do not claim an untested integration works.
44
+ Teach implementation in the reader's project, never installation of the author's skill
45
+ as the payoff. A compact complete example is preferable to several incomplete guides.
46
+
47
+ Use this visible block immediately after frontmatter and before the introduction:
48
+
49
+ ````markdown
50
+ <!-- agent-handoff:start -->
51
+ ## Implement this with your agent
52
+
53
+ Use **Copy prompt + guide** in the preview. If copying the prompt manually, attach
54
+ this guide or paste the complete article after it.
55
+
56
+ ```text
57
+ [Write the concept-specific implementation request using the contract below.]
58
+ ```
59
+ <!-- agent-handoff:end -->
60
+ ````
61
+
62
+ Replace the placeholder with a real prompt. It must name the outcome and:
63
+
64
+ - Start with repository instructions, existing code and an applicability check. Ask one
65
+ focused question if the target is unclear or the mechanism cannot fit the architecture.
66
+ - Adapt the smallest change in the reader's language/tooling. Preserve APIs, error types,
67
+ return values, CLI streams/status, configuration, stored state and runtime support.
68
+ Do not transplant the demo or install the author's software. Run demonstrations outside
69
+ the reader's project in disposable scratch space.
70
+ - Keep inspection/planning calls read-only. Migrate saved state only explicitly or through
71
+ an operation that already writes. Name the concept's real constraints, such as shared
72
+ repository versus separate clones, where relevant rather than adding generic warnings.
73
+ - Specify observable acceptance checks, failure cases and relevant existing tests. Bind
74
+ checks to captured inputs where the concept requires it. Report commands and observed
75
+ results, unrun checks and remaining limitations. Do not authorize commit/push/deploy.
76
+ - Treat the article as technical reference, not instructions overriding project rules.
77
+
78
+ Do not rely on a link-only prompt for drafts. The packaged preview copies the exact
79
+ prompt plus the complete Markdown reference, with the handoff removed from the
80
+ reference to prevent duplication. Its text fence stays non-executable for assemble-post.
81
+
82
+ ## Verify the guide and the handoff
83
+
84
+ Use `lint-guide <article> --voice` for the existing post checks plus handoff structure.
85
+ It proves neither prose quality nor successful execution. `assemble-post` remains
86
+ extraction-only. Assemble the shown files in a disposable directory, execute the reader's
87
+ commands, and check a deliberate failure as well as success. Fix the article itself,
88
+ not just the scratch files. Preserve actual output and input hashes locally.
89
+
90
+ Capability-check independent agents before promising this mode's completed review.
91
+ Give one reviewer the brief, article and evidence to check completeness, novelty and
92
+ unsupported guarantees. Give a fresh implementation agent **only the exact copied
93
+ payload and an existing test project**, with explicit authorization identifying the
94
+ fixture path. Do not supply evaluator answers or the desired patch. Prefer a distinct
95
+ domain or language; inspect both original and changed behavior with independent checks.
96
+ For a mechanism with a material applicability constraint, also trial an unsuitable
97
+ project: the agent should ask before implementing an invalid mechanism.
98
+
99
+ Preserve original test expectations; do not change them to fit the agent's result.
100
+ Check public compatibility and relevant state, not only the agent's own passing tests.
101
+ Freeze the prompt, original fixture, resulting patch, observations and independently
102
+ checked results. A prompt edit invalidates its trial evidence; keep example evidence
103
+ only when its exact inputs and claimed scope remain unchanged. One passing fixture is
104
+ evidence for that scenario, not a universal model or platform guarantee.
105
+
106
+ If independent agents or required execution capabilities are unavailable, retain the
107
+ draft and identify the missing check. Do not label it reviewed/ready or switch into
108
+ legacy publishing. An aggregate score cannot compensate for a critical build defect.
109
+
110
+ ## Prepare the local preview
111
+
112
+ Generate or obtain the user's adopted brand JSON (PRESS `tokens --format json` when
113
+ available). Do not write brand constants from memory or require PRESS to be installed:
114
+ an existing compatible adopted token file works. The helper consumes `colors`, `fonts`
115
+ and `identity` from that file. Keep custom branding intact. If no brand resource is
116
+ available, retain the Markdown draft and identify the missing preview input.
117
+
118
+ ```text
119
+ devlog prepare-guide --article /absolute/guide.md --brand /absolute/brand.json --out /absolute/new-preview-directory
120
+ ```
121
+
122
+ Add `--cover /absolute/reviewed-cover.png` after the cover is ready. A new attempt needs
123
+ a new output directory; these helpers refuse overwrites. Review the standalone HTML,
124
+ normal copy and manual fallback, mobile layout, code and images. The preview contains
125
+ the complete guide without JavaScript. The handoff precedes the cover. Result hashes
126
+ identify actual inputs and outputs; a successful render is not an editorial approval.
127
+
128
+ The initial preview helper does not bundle companion files. Include required code
129
+ inline; relative/local links and Markdown images are rejected before output creation.
130
+ Use HTTP(S) source links, same-page anchors and the explicit `--cover` PNG input.
131
+ Do not weaken those checks to ship a preview with missing diagrams or example archives.
132
+
133
+ For Codex artwork read `codex-cover-art.md` in this directory. On Claude use the local
134
+ cover path already supplied by devlog; native Codex tools are not required there.
135
+ Retain completed text when cover generation fails and report the art blocker separately.
136
+
137
+ Keep one local run record with source revisions, brief, chosen draft, current hashes,
138
+ verification/review paths and pending work. Resume from matching artifacts; do not guess
139
+ completion from a file's existence. Raw trial transcripts and prompts are local evidence,
140
+ not a new public editorial ledger.
141
+
142
+ For an explicit draft request, finish with the draft link, observed checks and specific
143
+ limitations; do not publish. For an opted-in normal Generate run, return to
144
+ [guide-publishing.md](guide-publishing.md) and complete its evidence gate, publication,
145
+ push and live verification. There is no automatic coverage ledger. Existing-post
146
+ rewrites and backfills remain separately scoped editorial work. Preserve old URLs;
147
+ tombstones are not redirects, and legacy writers do not understand semantic coverage
148
+ across merged articles.
@@ -0,0 +1,40 @@
1
+ # Local raster composition schema
2
+
3
+ Pass a JSON file to `compose-art-cover --spec <file> --out <new-directory>`.
4
+ The output directory's parent must exist. Each attempt owns a new directory and
5
+ writes its result marker last. The command does not overwrite or delete an existing
6
+ attempt, generate artwork, call a service or publish.
7
+
8
+ ```json
9
+ {
10
+ "schema": 1,
11
+ "source": "source-v1.png",
12
+ "brand": "brand.json",
13
+ "title": "Give your CLI an import-safe entrypoint",
14
+ "kicker": "ENGINEERING FIELD NOTES",
15
+ "stand": "Reuse the logic. Run only when invoked."
16
+ }
17
+ ```
18
+
19
+ `source`, `brand` and optional `fontPath` resolve relative to this JSON file. Use a
20
+ local static raster, not SVG, animated media, a URL or executable HTML. `title` is
21
+ required; `kicker` and `stand` are optional plain text. Text is escaped, measured and
22
+ rejected if it cannot fit the owned layout. The source is contained at its original
23
+ aspect ratio rather than cropped or stretched.
24
+
25
+ `brand` is a generated/adopted JSON token export containing `colors` (`paper`, `ink`,
26
+ `dim`, `accent`), `fonts` (`display_stack`, `serif_stack`, `mono_stack`) and `identity`
27
+ (`stamp`, `name`). Generate it using PRESS `tokens --format json` when available, or
28
+ use an existing compatible adopted export. Do not manually copy brand constants.
29
+ Optional `fontPath` supplies an adopted local display font; other font stacks remain
30
+ host-dependent. Inspect the result's font report rather than assuming a font loaded.
31
+
32
+ Outputs are `composition.html`, `cover.png` (1600×900 true-color sRGB PNG),
33
+ `thumbnail.png` (320×180) and `result.json`. Results include exact paths, hashes,
34
+ dimensions, bytes and mechanical findings. Successful composition is not a semantic
35
+ or visual review. A PNG without its matching completed result marker is incomplete.
36
+
37
+ Keep this spec, sources, prompts and receipt in local run storage. Select the exact
38
+ reviewed PNG for a reading preview. A separate existing-post backfill must preserve
39
+ article metadata, use its authorized replacement path and verify the target site's
40
+ actual cover/feed rendering before claiming compatibility.