@natjswenson/devlog 0.1.9 → 0.4.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/bin/devlog.js CHANGED
@@ -19,13 +19,19 @@ import kleur from 'kleur';
19
19
  //
20
20
  // For strict-token fields (project keys, repo names, branch names), separate
21
21
  // allowlist regexes apply additional structural constraints.
22
- const SHELL_QUOTE_BREAK = /[;&|`$()<>{}[\]*?!#~"'\\\n\r]/;
23
- const RE_GH_USER = /^[a-z0-9][a-z0-9-]*$/i;
24
- const RE_REPO_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
25
- const RE_OWNER_REPO = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
26
- const RE_PROJECT_KEY = /^[a-z0-9][a-z0-9._-]*$/i;
27
- const RE_BRANCH = /^[a-z0-9][a-z0-9._/-]*$/i;
28
- const FORBIDDEN_BRANCH_PARTS = /(^|\/)\.\.($|\/)/; // reject `..` as a path component
22
+ export const SHELL_QUOTE_BREAK = /[;&|`$()<>{}[\]*?!#~"'\\\n\r]/;
23
+ export const RE_GH_USER = /^[a-z0-9][a-z0-9-]*$/i;
24
+ export const RE_REPO_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
25
+ export const RE_OWNER_REPO = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
26
+ export const RE_PROJECT_KEY = /^[a-z0-9][a-z0-9._-]*$/i;
27
+ export const RE_BRANCH = /^[a-z0-9][a-z0-9._/-]*$/i;
28
+ // Repo-relative subdir used to scope `git log` to one skill in a monorepo.
29
+ // Same shape as a branch: no leading dash/slash, no shell metacharacters.
30
+ export const RE_PATH_FILTER = /^[a-z0-9][a-z0-9._/-]*$/i;
31
+ // Git tag prefix that marks a project's releases (e.g. `v` or `devlog-v`).
32
+ // Interpolated into `git tag --list '<tagPrefix>*'`; same safety as a path filter.
33
+ export const RE_TAG_PREFIX = /^[a-z0-9][a-z0-9._/-]*$/i;
34
+ export const FORBIDDEN_BRANCH_PARTS = /(^|\/)\.\.($|\/)/; // reject `..` as a path component
29
35
 
30
36
  const require = createRequire(import.meta.url);
31
37
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -34,6 +40,9 @@ const CONFIG_DIR = join(homedir(), '.claude', 'skills', 'devlog');
34
40
  const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
35
41
  const SKILL_DEST = join(CONFIG_DIR, 'SKILL.md');
36
42
  const PREVIEW_DIR = join(PACKAGE_ROOT, 'preview');
43
+ const VOICE_SRC_DIR = join(PACKAGE_ROOT, 'voice');
44
+ const VOICE_DEST_DIR = join(CONFIG_DIR, 'voice');
45
+ const GHOSTWRITER_VOICE_DIR = join(homedir(), '.claude', 'skills', 'ghostwriter', 'voice');
37
46
 
38
47
  const log = {
39
48
  info: (msg) => console.log(msg),
@@ -69,7 +78,7 @@ function tryExecArgs(cmd, args) {
69
78
  }
70
79
  }
71
80
 
72
- function expandHome(p) {
81
+ export function expandHome(p) {
73
82
  if (!p) return p;
74
83
  if (p === '~') return homedir();
75
84
  if (p.startsWith('~/')) return join(homedir(), p.slice(2));
@@ -93,7 +102,7 @@ function atomicWriteJSON(path, data) {
93
102
  }
94
103
 
95
104
  // Validate a config object before writing. Throws with a user-facing message on failure.
96
- function validateConfig(config) {
105
+ export function validateConfig(config) {
97
106
  if (!config || typeof config !== 'object') throw new Error('Config must be an object');
98
107
  const required = ['targetRepo', 'gitAuthor', 'githubUser', 'projects'];
99
108
  for (const k of required) {
@@ -113,6 +122,18 @@ function validateConfig(config) {
113
122
  throw new Error(`branch must be a valid git branch name (no leading dash, no '..'): got ${JSON.stringify(config.branch)}`);
114
123
  }
115
124
  }
125
+ if ('voicePath' in config) {
126
+ // Optional: directory holding the voice profile used to write entries. Read by
127
+ // the skill with the Read tool only — never shell-interpolated — so the only
128
+ // hard requirement is no shell metacharacters and no leading dash. A leading `~`
129
+ // is allowed (the skill expands it); we test the expanded form so an absolute
130
+ // path has no `~` left to trip the shell-quote-break check. Existence is checked
131
+ // at prompt time (and at runtime, with a fallback chain), not here.
132
+ const expanded = typeof config.voicePath === 'string' ? expandHome(config.voicePath) : config.voicePath;
133
+ if (typeof config.voicePath !== 'string' || SHELL_QUOTE_BREAK.test(expanded) || expanded.trim().startsWith('-')) {
134
+ throw new Error(`voicePath must be a path with no shell metacharacters and no leading dash: got ${JSON.stringify(config.voicePath)}`);
135
+ }
136
+ }
116
137
  if (!Array.isArray(config.projects)) {
117
138
  throw new Error('projects must be an array');
118
139
  }
@@ -130,6 +151,22 @@ function validateConfig(config) {
130
151
  if (!RE_OWNER_REPO.test(p.remote)) {
131
152
  throw new Error(`project.remote must match <owner>/<repo>: ${JSON.stringify(p.remote)}`);
132
153
  }
154
+ if ('pathFilter' in p) {
155
+ // Optional: scope this project's commits to a repo subdirectory (e.g. a
156
+ // single skill in a monorepo). Interpolated into `git log -- <pathFilter>`,
157
+ // so enforce the same no-metacharacter / no-`..` safety as branch names.
158
+ if (typeof p.pathFilter !== 'string' || !RE_PATH_FILTER.test(p.pathFilter) || FORBIDDEN_BRANCH_PARTS.test(p.pathFilter)) {
159
+ throw new Error(`project.pathFilter must be a repo-relative subdir (no leading dash/slash, no '..', no shell metacharacters): ${JSON.stringify(p.pathFilter)}`);
160
+ }
161
+ }
162
+ if ('tagPrefix' in p) {
163
+ // Optional: the prefix of the git tags that mark this project's releases
164
+ // (e.g. `devlog-v`). Interpolated into `git tag --list '<tagPrefix>*'`, so
165
+ // enforce the same no-metacharacter / no-`..` safety as path filters.
166
+ if (typeof p.tagPrefix !== 'string' || !RE_TAG_PREFIX.test(p.tagPrefix) || FORBIDDEN_BRANCH_PARTS.test(p.tagPrefix)) {
167
+ throw new Error(`project.tagPrefix must be a tag prefix (no leading dash/slash, no '..', no shell metacharacters): ${JSON.stringify(p.tagPrefix)}`);
168
+ }
169
+ }
133
170
  if ('label' in p) {
134
171
  // Label is rendered as React text content only — never shell-interpolated,
135
172
  // never used in URLs, never used as a filesystem path. React escapes all
@@ -197,7 +234,7 @@ async function confirmOverwrite(label, path) {
197
234
  }
198
235
 
199
236
  // ─── prompt validators (reused across init and add-project) ──────────────────
200
- const VALIDATORS = {
237
+ export const VALIDATORS = {
201
238
  gitAuthor: (v) => {
202
239
  if (v.trim().length === 0) return 'Required';
203
240
  if (SHELL_QUOTE_BREAK.test(v)) return 'Invalid characters (no quotes, backticks, dollar signs, semicolons, parens, or shell metacharacters)';
@@ -217,6 +254,20 @@ const VALIDATORS = {
217
254
  return true;
218
255
  },
219
256
  ownerRepo: (v) => RE_OWNER_REPO.test(v.trim()) || 'Expected <owner>/<repo>, no leading dash, alphanumeric + ._- only',
257
+ voicePath: (v) => {
258
+ // Optional. Blank means "use ghostwriter's voice dir if present, else the bundled default".
259
+ if (!v || v.trim() === '') return true;
260
+ if (SHELL_QUOTE_BREAK.test(v)) return 'Invalid characters (no quotes, backticks, dollar signs, semicolons, parens, or shell metacharacters)';
261
+ if (v.trim().startsWith('-')) return 'Path cannot start with a dash';
262
+ return existsSync(expandHome(v.trim())) || 'Path does not exist';
263
+ },
264
+ tagPrefix: (v) => {
265
+ // Optional. Blank/`v` is the default. Used in `git tag --list '<prefix>*'`.
266
+ const t = (v || '').trim();
267
+ if (t === '') return true;
268
+ if (!RE_TAG_PREFIX.test(t) || FORBIDDEN_BRANCH_PARTS.test(t)) return 'Invalid prefix (no leading dash/slash, no "..", no shell metacharacters)';
269
+ return true;
270
+ },
220
271
  label: (v) => {
221
272
  // Label is React text content only — apostrophes and most punctuation are fine.
222
273
  // Reject only control chars and overlong values.
@@ -262,6 +313,13 @@ async function promptForProject(defaults = {}) {
262
313
  initial: (_p, values) => detectProjectRemote(expandHome(values.path)) || initialRemote,
263
314
  validate: VALIDATORS.ownerRepo,
264
315
  },
316
+ {
317
+ type: 'text',
318
+ name: 'tagPrefix',
319
+ message: 'Release tag prefix (optional, e.g. "v" or "myproject-v"):',
320
+ initial: defaults.tagPrefix || 'v',
321
+ validate: VALIDATORS.tagPrefix,
322
+ },
265
323
  ], { onCancel: () => process.exit(1) });
266
324
 
267
325
  const out = {
@@ -270,6 +328,9 @@ async function promptForProject(defaults = {}) {
270
328
  remote: answers.remote.trim(),
271
329
  };
272
330
  if (answers.label && answers.label.trim()) out.label = answers.label.trim();
331
+ // Only persist tagPrefix when it differs from the default `v` (keeps configs clean).
332
+ const tagPrefix = (answers.tagPrefix || '').trim();
333
+ if (tagPrefix && tagPrefix !== 'v') out.tagPrefix = tagPrefix;
273
334
  return out;
274
335
  }
275
336
 
@@ -282,12 +343,16 @@ async function cmdInit() {
282
343
  gitAuthor: detectGitName() || '',
283
344
  githubUser: detectGhUser() || '',
284
345
  targetRepoName: 'daily-dev-log',
346
+ // Pre-fill the voice path with ghostwriter's voice dir if it's installed — that's
347
+ // the most likely place a user already keeps their voice profile.
348
+ voicePath: existsSync(GHOSTWRITER_VOICE_DIR) ? GHOSTWRITER_VOICE_DIR : '',
285
349
  };
286
350
 
287
351
  const answers = await prompts([
288
- { type: 'text', name: 'gitAuthor', message: 'Your name (used to filter `git log --author`):', initial: defaults.gitAuthor, validate: VALIDATORS.gitAuthor },
352
+ { type: 'text', name: 'gitAuthor', message: 'Your name (retained for backward compatibility; not currently rendered on entries):', initial: defaults.gitAuthor, validate: VALIDATORS.gitAuthor },
289
353
  { type: 'text', name: 'githubUser', message: 'Your GitHub username:', initial: defaults.githubUser, validate: VALIDATORS.githubUser },
290
354
  { type: 'text', name: 'targetRepoName', message: 'Name of the repo where dev logs will be published:', initial: defaults.targetRepoName, validate: VALIDATORS.targetRepoName },
355
+ { type: 'text', name: 'voicePath', message: 'Voice profile directory (optional — blank uses ghostwriter\'s if present, else the bundled default):', initial: defaults.voicePath, validate: VALIDATORS.voicePath },
291
356
  ], { onCancel: () => process.exit(1) });
292
357
 
293
358
  // Optionally register projects in a loop. First time defaults to "yes".
@@ -313,11 +378,16 @@ async function cmdInit() {
313
378
  }
314
379
 
315
380
  const targetRepo = `${answers.githubUser}/${answers.targetRepoName}`;
381
+ // Store the expanded absolute path (consistent with project.path) so the persisted
382
+ // config never carries a `~` that would later trip the shell-quote-break check.
383
+ const rawVoicePath = (answers.voicePath || '').trim();
384
+ const voicePath = rawVoicePath ? expandHome(rawVoicePath) : '';
316
385
  const config = validateConfig({
317
386
  targetRepo,
318
387
  branch: 'main',
319
388
  gitAuthor: answers.gitAuthor,
320
389
  githubUser: answers.githubUser,
390
+ ...(voicePath ? { voicePath } : {}),
321
391
  projects,
322
392
  });
323
393
 
@@ -334,6 +404,7 @@ async function cmdInit() {
334
404
  log.info(` Git author: ${config.gitAuthor}`);
335
405
  log.info(` GitHub user: ${config.githubUser}`);
336
406
  log.info(` Branch: ${config.branch}`);
407
+ log.info(` Voice profile: ${config.voicePath || '(ghostwriter if present, else bundled default)'}`);
337
408
  log.info(` Projects: ${config.projects.length === 0 ? '(none — add later with `devlog add-project`)' : config.projects.map((p) => p.key).join(', ')}`);
338
409
  log.info(` Skill location: ${CONFIG_DIR}`);
339
410
 
@@ -347,7 +418,7 @@ async function cmdInit() {
347
418
  log.warn(`Repo github.com/${targetRepo} already exists. Will use it as-is.`);
348
419
  } else {
349
420
  log.step(`Creating github.com/${targetRepo}...`);
350
- const r = spawnSync('gh', ['repo', 'create', targetRepo, '--public', '--description', 'Daily dev log', '--add-readme'], { stdio: 'inherit' });
421
+ const r = spawnSync('gh', ['repo', 'create', targetRepo, '--public', '--description', 'Release dev log', '--add-readme'], { stdio: 'inherit' });
351
422
  if (r.status !== 0) {
352
423
  log.err('Failed to create repo. Check `gh` permissions.');
353
424
  process.exit(1);
@@ -374,16 +445,33 @@ async function cmdInit() {
374
445
  log.warn('Skipped config.json');
375
446
  }
376
447
 
448
+ // Install the bundled voice template as the fallback voice profile. The skill
449
+ // resolves voice in this order: config.voicePath → ghostwriter's voice dir →
450
+ // this bundled copy. Installing it guarantees the last fallback always exists.
451
+ if (!existsSync(VOICE_DEST_DIR)) {
452
+ mkdirSync(VOICE_DEST_DIR, { recursive: true, mode: 0o700 });
453
+ }
454
+ for (const [src, dest] of [['voice-profile.example.md', 'voice-profile.md'], ['voice-notes.example.md', 'voice-notes.md']]) {
455
+ const s = join(VOICE_SRC_DIR, src);
456
+ const d = join(VOICE_DEST_DIR, dest);
457
+ if (existsSync(s) && (await confirmOverwrite(`voice/${dest}`, d))) {
458
+ copyFileSync(s, d);
459
+ log.ok(`Installed voice/${dest} → ${d}`);
460
+ }
461
+ }
462
+
377
463
  log.info('\n' + kleur.bold().green('Setup complete.') + '\n');
378
464
  log.info('Next steps:');
379
465
  if (config.projects.length === 0) {
380
466
  log.info(` 1. Add a project: ${kleur.cyan('npx @natjswenson/devlog add-project')}`);
381
- log.info(' 2. Make some commits in the project');
467
+ log.info(' 2. Tag a release in the project (e.g. `git tag v0.1.0`)');
468
+ log.info(` 3. In Claude Code, run: ${kleur.cyan('/devlog')}`);
469
+ log.info(` 4. Preview locally: ${kleur.cyan('npx @natjswenson/devlog preview')}`);
382
470
  } else {
383
- log.info(' 1. Make some commits in a registered project');
471
+ log.info(' 1. Tag a release in a registered project (e.g. `git tag v0.1.0`)');
472
+ log.info(` 2. In Claude Code, run: ${kleur.cyan('/devlog')}`);
473
+ log.info(` 3. Preview locally: ${kleur.cyan('npx @natjswenson/devlog preview')}`);
384
474
  }
385
- log.info(` 2. In Claude Code, run: ${kleur.cyan('/devlog')}`);
386
- log.info(` 3. Preview locally: ${kleur.cyan('npx @natjswenson/devlog preview')}`);
387
475
  log.info('');
388
476
  }
389
477
 
@@ -458,11 +546,14 @@ async function cmdConfig() {
458
546
  log.info(`Branch: ${config.branch || 'main'}`);
459
547
  log.info(`Git author: ${config.gitAuthor || '?'}`);
460
548
  log.info(`GitHub user: ${config.githubUser || '?'}`);
549
+ log.info(`Voice path: ${config.voicePath || kleur.dim('(ghostwriter if present, else bundled default)')}`);
461
550
  log.info(`Projects (${(config.projects || []).length}):`);
462
551
  for (const p of config.projects || []) {
463
552
  log.info(` ${kleur.cyan(p.key)}${p.label ? ` (${p.label})` : ''}`);
464
553
  log.info(kleur.dim(` path: ${p.path}`));
465
554
  log.info(kleur.dim(` remote: github.com/${p.remote}`));
555
+ if (p.pathFilter) log.info(kleur.dim(` scope: ${p.pathFilter}/`));
556
+ log.info(kleur.dim(` tags: ${p.tagPrefix || 'v'}*`));
466
557
  }
467
558
  log.info('');
468
559
  }
@@ -526,7 +617,7 @@ async function cmdPreview() {
526
617
  // ─── help ────────────────────────────────────────────────────────────────────
527
618
  function printHelp() {
528
619
  console.log(`
529
- ${kleur.bold('@natjswenson/devlog')} v${readPackageVersion()} — daily dev log generator
620
+ ${kleur.bold('@natjswenson/devlog')} v${readPackageVersion()} — release dev log generator
530
621
 
531
622
  Usage:
532
623
  ${kleur.cyan('npx @natjswenson/devlog init')} One-time setup: create your dev-log repo, install the skill, write config
@@ -542,31 +633,36 @@ Issues: https://github.com/natejswenson/devlog/issues
542
633
  }
543
634
 
544
635
  // ─── dispatch ────────────────────────────────────────────────────────────────
545
- const arg = process.argv[2];
546
- switch (arg) {
547
- case 'init':
548
- cmdInit();
549
- break;
550
- case 'add-project':
551
- cmdAddProject();
552
- break;
553
- case 'config':
554
- cmdConfig();
555
- break;
556
- case 'preview':
557
- cmdPreview();
558
- break;
559
- case '-v':
560
- case '--version':
561
- console.log(readPackageVersion());
562
- break;
563
- case undefined:
564
- case '-h':
565
- case '--help':
566
- printHelp();
567
- break;
568
- default:
569
- log.err(`Unknown command: ${arg}`);
570
- printHelp();
571
- process.exit(1);
636
+ // Only run the CLI dispatch when this file is executed directly, not when it is
637
+ // imported (e.g. by the test suite). Importing the module must have no side effects.
638
+ const isMain = process.argv[1] === fileURLToPath(import.meta.url);
639
+ if (isMain) {
640
+ const arg = process.argv[2];
641
+ switch (arg) {
642
+ case 'init':
643
+ cmdInit();
644
+ break;
645
+ case 'add-project':
646
+ cmdAddProject();
647
+ break;
648
+ case 'config':
649
+ cmdConfig();
650
+ break;
651
+ case 'preview':
652
+ cmdPreview();
653
+ break;
654
+ case '-v':
655
+ case '--version':
656
+ console.log(readPackageVersion());
657
+ break;
658
+ case undefined:
659
+ case '-h':
660
+ case '--help':
661
+ printHelp();
662
+ break;
663
+ default:
664
+ log.err(`Unknown command: ${arg}`);
665
+ printHelp();
666
+ process.exit(1);
667
+ }
572
668
  }
@@ -3,17 +3,22 @@
3
3
  "branch": "main",
4
4
  "gitAuthor": "Your Name",
5
5
  "githubUser": "yourusername",
6
+ "voicePath": "~/.claude/skills/ghostwriter/voice",
6
7
  "projects": [
7
8
  {
8
9
  "key": "midnight-side-quest",
9
10
  "label": "Midnight Side Quest",
10
11
  "path": "/Users/yourusername/code/midnight-side-quest",
11
- "remote": "yourusername/midnight-side-quest"
12
+ "remote": "yourusername/midnight-side-quest",
13
+ "tagPrefix": "v"
12
14
  },
13
15
  {
14
- "key": "todays-existential-crisis",
15
- "path": "/Users/yourusername/code/todays-existential-crisis",
16
- "remote": "yourusername/todays-existential-crisis"
16
+ "key": "devlog",
17
+ "label": "Devlog",
18
+ "path": "/Users/yourusername/code/your-monorepo",
19
+ "remote": "yourusername/your-monorepo",
20
+ "pathFilter": "skills/devlog",
21
+ "tagPrefix": "devlog-v"
17
22
  }
18
23
  ]
19
24
  }
@@ -1,6 +1,6 @@
1
1
  # React example — devlog
2
2
 
3
- Drop-in React components for rendering your daily dev log on any React-based site.
3
+ Drop-in React components for rendering your release dev log on any React-based site.
4
4
 
5
5
  ## What's here
6
6
 
@@ -3,11 +3,12 @@ import { DEVLOG_CONFIG as DEFAULT_CONFIG } from './devlog-config.js';
3
3
 
4
4
  // Allowlist of frontmatter keys we recognize. Anything else is ignored —
5
5
  // prevents prototype-pollution via crafted keys like `__proto__`.
6
- const FRONTMATTER_KEYS = new Set(['title', 'date', 'project', 'summary']);
6
+ // `version` is the release tag this entry corresponds to (e.g. "v0.2.0").
7
+ const FRONTMATTER_KEYS = new Set(['title', 'date', 'project', 'summary', 'version']);
7
8
 
8
9
  /**
9
10
  * Parse YAML-ish frontmatter from a markdown string.
10
- * Returns { metadata: { title, date, project, summary }, body: string }
11
+ * Returns { metadata: { title, date, project, summary, version }, body: string }
11
12
  */
12
13
  function parseFrontmatter(raw) {
13
14
  const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
@@ -24,19 +25,22 @@ function parseFrontmatter(raw) {
24
25
  }
25
26
 
26
27
  // Schema validation for fetched manifest. Reject anything that isn't shaped
27
- // like { entries: [{ date, file, title, summary }, ...] } so a hostile commit
28
- // to the dev-log repo can't crash the page.
28
+ // like { entries: [{ date, file, title, summary, version? }, ...] } so a hostile
29
+ // commit to the dev-log repo can't crash the page. `version` is optional and
30
+ // only kept when it's a clean tag-ish string.
29
31
  function validateManifest(data) {
30
32
  if (!data || typeof data !== 'object') return null;
31
33
  if (!Array.isArray(data.entries)) return null;
32
34
  const entries = [];
33
35
  for (const e of data.entries) {
34
36
  if (!e || typeof e !== 'object') continue;
35
- const { date, file, title, summary } = e;
37
+ const { date, file, title, summary, version } = e;
36
38
  if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
37
39
  if (typeof file !== 'string' || !/^[a-zA-Z0-9._-]+\.md$/.test(file)) continue;
38
40
  if (typeof title !== 'string' || typeof summary !== 'string') continue;
39
- entries.push({ date, file, title, summary });
41
+ const entry = { date, file, title, summary };
42
+ if (typeof version === 'string' && /^[a-zA-Z0-9._-]+$/.test(version)) entry.version = version;
43
+ entries.push(entry);
40
44
  }
41
45
  return { entries };
42
46
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@natjswenson/devlog",
3
- "version": "0.1.9",
4
- "description": "Daily dev log generator Claude Code skill + preview app for publishing git-based dev logs to your site",
3
+ "version": "0.4.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",
5
5
  "license": "MIT",
6
6
  "author": "Nate Swenson",
7
7
  "homepage": "https://github.com/natejswenson/devlog",
@@ -18,6 +18,7 @@
18
18
  "claude-skill",
19
19
  "build-in-public",
20
20
  "git",
21
+ "release-notes",
21
22
  "blog"
22
23
  ],
23
24
  "type": "module",
@@ -28,6 +29,7 @@
28
29
  "bin/",
29
30
  "preview/",
30
31
  "examples/",
32
+ "voice/",
31
33
  "SKILL.md",
32
34
  "SECURITY.md",
33
35
  "CHANGELOG.md",
@@ -39,6 +41,7 @@
39
41
  "node": ">=18"
40
42
  },
41
43
  "scripts": {
44
+ "test": "node --test \"tests/**/*.test.mjs\"",
42
45
  "audit": "npm audit --audit-level=moderate"
43
46
  },
44
47
  "dependencies": {
@@ -49,6 +52,6 @@
49
52
  "react-dom": "18.3.1",
50
53
  "react-markdown": "9.1.0",
51
54
  "remark-gfm": "4.0.1",
52
- "vite": "8.0.10"
55
+ "vite": "8.0.16"
53
56
  }
54
57
  }
package/preview/demo.js CHANGED
@@ -25,31 +25,33 @@ function offsetDate(daysAgo) {
25
25
 
26
26
  const ENTRIES = [
27
27
  {
28
+ version: 'v0.4.0',
28
29
  date: offsetDate(0),
29
30
  title: 'You are looking at fake data',
30
- summary: "Hi. These aren't your entries. Your entries are 30 seconds away.",
31
- body: `## What I Built
31
+ summary: "Hi. These aren't your releases. Your releases are 30 seconds away.",
32
+ body: `## What Shipped
32
33
 
33
34
  Nothing. I'm a placeholder. A handsome one, but still a placeholder.
34
35
 
35
36
  You're seeing this screen because the preview app couldn't find env vars
36
37
  pointing at your dev-log repo. Once that's fixed, this entire feed gets
37
38
  replaced with real entries, generated by the \`/devlog\` skill from your
38
- actual git commits.
39
+ actual version releases — written in your voice.
39
40
 
40
41
  ## What's Next
41
42
 
42
43
  You. Setting things up. Probably while half-watching a YouTube tutorial
43
44
  about something unrelated. We believe in you.
44
45
 
45
- ## Public Commits
46
+ ## Commits
46
47
 
47
- - [demo] make placeholder more passive-aggressive ([abcd123](#))
48
- - [demo] add a tiny bit of charm ([def4567](#))
48
+ - make placeholder more passive-aggressive ([abcd123](#))
49
+ - add a tiny bit of charm ([def4567](#))
49
50
  `,
50
51
  },
51
52
  {
52
- date: offsetDate(1),
53
+ version: 'v0.3.0',
54
+ date: offsetDate(2),
53
55
  title: 'How to make this screen go away',
54
56
  summary: 'Two paths: the lazy one (recommended) and the manual one (also fine).',
55
57
  body: `## The lazy path
@@ -58,7 +60,7 @@ about something unrelated. We believe in you.
58
60
  npx @natjswenson/devlog init
59
61
  \`\`\`
60
62
 
61
- Answer four prompts. The CLI creates your dev-log repo on GitHub, installs
63
+ Answer a few prompts. The CLI creates your dev-log repo on GitHub, installs
62
64
  the Claude Code skill, and writes your config. Run \`npx @natjswenson/devlog preview\`
63
65
  again. This entry vanishes. You feel powerful.
64
66
 
@@ -75,18 +77,20 @@ VITE_DEVLOG_PROJECTS=[{"key":"myproject","label":"My Project"}]
75
77
 
76
78
  ## What's Next
77
79
 
78
- The real preview, with your real entries. Try it.
80
+ The real preview, with your real releases. Try it.
79
81
  `,
80
82
  },
81
83
  {
82
- date: offsetDate(3),
84
+ version: 'v0.2.0',
85
+ date: offsetDate(5),
83
86
  title: "Why you'd actually want this",
84
- summary: 'Build in public, but with style. And without remembering to write blog posts.',
85
- body: `## What I Built
87
+ summary: 'Build in public, by release — with style, and without remembering to write blog posts.',
88
+ body: `## What Shipped
86
89
 
87
- The whole point: you commit code as usual. You run \`/devlog\` in Claude Code.
88
- The skill reads today's commits and writes a *narrative* entry — not "fix typo,
89
- fix typo again, ok actually fix it" but real prose about what you built and why.
90
+ The whole point: you tag a release as usual. You run \`/devlog\` in Claude Code.
91
+ The skill finds tags that don't have an entry yet and writes a *narrative*
92
+ release note — not "fix typo, fix typo again, ok actually fix it" but real
93
+ prose about what shipped and why, in your voice.
90
94
 
91
95
  That entry gets pushed to your dev-log repo. Your site (or this preview app,
92
96
  deployed to Vercel/Netlify/Cloudflare) renders it.
@@ -95,29 +99,30 @@ The result: you ship in public without ever opening a blog post editor.
95
99
 
96
100
  ## What's Next
97
101
 
98
- You'll set this up. You'll ship something on day one. You'll feel slightly
102
+ You'll set this up. You'll tag a release on day one. You'll feel slightly
99
103
  smug about it on the train tomorrow. We're rooting for you.
100
104
 
101
- ## Public Commits
105
+ ## Commits
102
106
 
103
- - [demo] write hopeful pep talk ([eeee101](#))
107
+ - write hopeful pep talk ([eeee101](#))
104
108
  `,
105
109
  },
106
110
  {
107
- date: offsetDate(7),
111
+ version: 'v0.1.0',
112
+ date: offsetDate(9),
108
113
  title: "Things this is not",
109
114
  summary: 'A short list, for the avoidance of disappointment.',
110
115
  body: `## Not features
111
116
 
112
117
  - A blog CMS. There are sixty of those. Use one if you want one.
113
118
  - A social network. Please don't.
114
- - An AI ghostwriter for marketing copy. The narratives come from *your*
115
- commits. Garbage in, garbage out.
119
+ - A marketing-copy generator. The narratives come from *your* releases, in
120
+ *your* voice. Garbage in, garbage out.
116
121
  - A way to make your past coding choices look better in retrospect. Sorry.
117
122
 
118
123
  ## Is features
119
124
 
120
- - A way to ship dev log entries without context-switching out of Claude Code.
125
+ - A way to ship release notes without context-switching out of Claude Code.
121
126
  - A static, no-backend pipeline (manifest.json + markdown on GitHub).
122
127
  - Components you can drop into your own React site, or the preview app you
123
128
  can deploy as a standalone dev log.
@@ -132,9 +137,10 @@ Replace this fake entry with a real one. It's right there. Just go.
132
137
  const MANIFEST = {
133
138
  entries: ENTRIES.map((e) => ({
134
139
  date: e.date,
135
- file: `${e.date}.md`,
140
+ file: `${e.version}.md`,
136
141
  title: e.title,
137
142
  summary: e.summary,
143
+ version: e.version,
138
144
  })),
139
145
  };
140
146
 
@@ -143,6 +149,7 @@ function entryMarkdown(entry) {
143
149
  title: "${entry.title.replace(/"/g, '\\"')}"
144
150
  date: ${entry.date}
145
151
  project: ${DEMO_PROJECT_KEY}
152
+ version: ${entry.version}
146
153
  summary: "${entry.summary.replace(/"/g, '\\"')}"
147
154
  ---
148
155
 
@@ -156,9 +163,9 @@ function demoResponse(url) {
156
163
  headers: { 'content-type': 'application/json' },
157
164
  });
158
165
  }
159
- const m = url.match(/(\d{4}-\d{2}-\d{2})\.md$/);
166
+ const m = url.match(/\/([a-zA-Z0-9._-]+)\.md$/);
160
167
  if (m) {
161
- const entry = ENTRIES.find((e) => e.date === m[1]);
168
+ const entry = ENTRIES.find((e) => e.version === m[1]);
162
169
  if (entry) {
163
170
  return new Response(entryMarkdown(entry), {
164
171
  status: 200,
@@ -0,0 +1,16 @@
1
+ # Voice notes — recent corrections
2
+
3
+ This file overrides `voice-profile.md` wherever they conflict. Put your most recent,
4
+ explicit corrections here — the things you keep having to fix in generated entries. Keep
5
+ it short and specific; it is read every time an entry is generated.
6
+
7
+ ## Defaults (safe to keep)
8
+ - Write for the reader, not as a diary. Lead with the change and its impact, not "I".
9
+ - No em dashes; use a comma or semicolon.
10
+ - No tacked-on punchy filler lines and no rhetorical fragment lists for rhythm.
11
+ - Never invent metrics, motivations, or outcomes the commits don't support.
12
+ - A release entry is a release note, not a changelog dump — group commits into the
13
+ handful of changes that actually matter and explain why.
14
+
15
+ ## Your corrections
16
+ (Append your own as they come up.)
@@ -0,0 +1,37 @@
1
+ # Voice profile — (your name)
2
+
3
+ This is the fallback voice profile devlog uses when no other profile is found. It is
4
+ generic. Replace it with your own — or, better, point `voicePath` in `config.json` at a
5
+ richer profile you already maintain (e.g. ghostwriter's `voice/`). **`voice-notes.md` in
6
+ the same directory overrides this file wherever they conflict.**
7
+
8
+ devlog reads only `voice-profile.md` and `voice-notes.md`. It never reads `algorithm.md`
9
+ (LinkedIn reach tuning) — a dev log is not a LinkedIn feed, so reach rules do not apply.
10
+
11
+ ## Voice & tone
12
+ Warm, practical, honest. A builder writing release notes for people who follow along — no
13
+ hype, no doom, no marketing gloss. Explain what shipped and why it matters in plain terms.
14
+
15
+ ## Sentence rhythm & structure
16
+ - Short sentences, generous white space, one idea per line or per tiny paragraph.
17
+ - Lead with the change, then the reason. Build to a crisp takeaway.
18
+ - A short bullet list is fine to enumerate "what changed."
19
+
20
+ ## Openers (how to start a release entry)
21
+ - A sharp statement of what shipped: "v0.3.0 makes the log release-driven."
22
+ - A short framing of the problem the release solves.
23
+
24
+ ## Closers
25
+ - A reframe or a genuine forward-looking line about what's next. Not a forced question.
26
+
27
+ ## Vocabulary & tics
28
+ - Plain, modern, conversational; contractions; no corporate jargon or buzzwords.
29
+ - Name real things: features, files, versions. Specifics over abstractions.
30
+
31
+ ## Emoji & hashtags
32
+ - Emoji: sparing or none. Hashtags: none.
33
+
34
+ ## Never do
35
+ - No hype words ("game-changer", "revolutionary"), no doom, no cynicism.
36
+ - No corporate jargon, no fake humility, no fabricated metrics or motivations.
37
+ - Don't pad. If a line isn't carrying weight, cut it.