Book-Addition Chronofile Posts — Implementation Plan (IMP-1)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Generate one Chronofile post per library book so the Chronofile doubles as a running accession log, with each book's date sourced from the accession record when one matches and the git first-add date otherwise.
Architecture: A prebuild generator (scripts/build-book-chronofile.mjs) mirrors the reader's catalog discovery (src/loaders/catalogLoader.ts), reads the accession filenames in .wiki/inventory/items/, and emits committed markdown posts under src/content/chronofile/books/. The impure generator delegates all decisions to a pure, unit-tested lib (scripts/lib/chronofilePosts.mjs). The post route becomes a rest route ([...slug].astro) so nested book-post ids (books/<date>-<slug>) resolve alongside the flat hand-written inaugural.
Tech Stack: Astro 5 content collections (native glob loader), Node ESM .mjs generators (system unzip + git via execFileSync), Vitest.
Spec: docs/superpowers/specs/2026-07-07-book-addition-chronofile-posts-design.md
Pluck note: This is a dev-xp_work pluck (IMP-1). Completion is a local squash-merge back to main — do NOT invoke superpowers:finishing-a-development-branch. The pluck owns the merge/yank/housekeeping steps after this plan's tasks pass.
Context the engineer needs
- Build cwd is
apps/reader/. All.mjsgenerators run from there; repo root ispath.resolve(process.cwd(), '../..'). .mjscannotimportbooks.config.ts(it's TypeScript). The established pattern (scripts/build-bulk-redirects.mjs:25-31) regex-parses the source file:const re = /\{\s*slug:\s*'([^']+)',\s*epubPath:\s*'([^']+)'/g;- Catalog discovery to mirror (
src/loaders/catalogLoader.ts:17-44): everything matching repo-root*/*.epub(skip any path containing/apps/, skip.epub.backup, skip excluded slugtest_book), deduped by directory preferring<slug>/<slug>.epub; plusbooks.configentries whose EPUB lives deeper than one level (the WDSD documents). This is the exact set shown in the live library, so mirroring it makes the post set match the catalog. - Accession records:
.wiki/inventory/items/*.md, filename<title-slug>--<YYYY-MM-DD>--<accession-id>.md(e.g.50-years-of-the-design-science-revolution-and-the-world-game--1977-01-01--bv-book-2023-0022.md)._index.mdis not a record. Single hyphens appear inside<title-slug>;--is only the field separator. - Vitest include already covers
.mjstests (vitest.config.ts→include: ['src/**/*.test.ts', 'scripts/**/*.test.mjs']). No config change needed. Thescripts/lib/pattern with a co-located.test.mjsalready exists (scripts/lib/bookRedirectRules.mjs+.test.mjs). - The generators are standalone npm scripts, not wired into
build(buildis justastro build && pagefind …). They emit committed artifacts consumed by the build. Follow that:chronofile-booksgenerates committed post files; they're committed to git and the build reads them. - Do NOT touch the hand-written inaugural
src/content/chronofile/2026-07-06-set-free-as-pattern.md. Generated posts live only underbooks/; the generator clears onlybooks/. - Chronofile content id shape (glob loader,
base: './src/content/chronofile'):books/<date>-<slug>.md→ idbooks/<date>-<slug>(keeps the directory prefix), and2026-07-06-set-free-as-pattern.md→ id2026-07-06-set-free-as-pattern. The list page already links/chronofile/${p.id}, so the post route must accept a slashed slug.
File structure
- Create
apps/reader/scripts/lib/chronofilePosts.mjs— pure helpers:normalizeKey,buildAccessionIndex,resolveDate,bookPostMarkdown. - Create
apps/reader/scripts/lib/chronofilePosts.test.mjs— Vitest unit tests for the four helpers. - Create
apps/reader/scripts/build-book-chronofile.mjs— the impure generator (discover + OPF title/author + git date + emit). - Modify
apps/reader/package.json— add thechronofile-booksscript. - Rename
apps/reader/src/pages/chronofile/[slug].astro→apps/reader/src/pages/chronofile/[...slug].astro(rest route). - Modify
apps/reader/src/pages/chronofile/index.astro— render tag chips on list items. - Generated (committed, not hand-edited)
apps/reader/src/content/chronofile/books/<date>-<slug>.md— one per catalog book.
Task 1: Pure lib — normalization, accession index, date resolution, post markdown
Files:
-
Create:
apps/reader/scripts/lib/chronofilePosts.mjs -
Test:
apps/reader/scripts/lib/chronofilePosts.test.mjs -
Step 1: Write the failing tests
Create apps/reader/scripts/lib/chronofilePosts.test.mjs:
import { describe, it, expect } from 'vitest';
import {
normalizeKey,
buildAccessionIndex,
resolveDate,
bookPostMarkdown,
} from './chronofilePosts.mjs';
describe('normalizeKey', () => {
it('converges underscores and hyphens to the same key', () => {
expect(normalizeKey('operating_manual_for_spaceship_earth')).toBe(
'operating-manual-for-spaceship-earth',
);
expect(normalizeKey('operating-manual-for-spaceship-earth')).toBe(
'operating-manual-for-spaceship-earth',
);
});
it('lowercases a title and strips punctuation', () => {
expect(normalizeKey('Operating Manual for Spaceship Earth')).toBe(
'operating-manual-for-spaceship-earth',
);
});
it('trims leading/trailing separators and collapses runs', () => {
expect(normalizeKey(' Earth, Inc. ')).toBe('earth-inc');
});
});
describe('buildAccessionIndex', () => {
const files = [
'operating-manual-for-spaceship-earth--2011-11-14--bv-book-2011-0001.md',
'50-years-of-the-design-science-revolution-and-the-world-game--1977-01-01--bv-book-2023-0022.md',
'_index.md',
'not-a-record.txt',
];
it('maps normalized title slug -> accession date, skipping non-records', () => {
const idx = buildAccessionIndex(files);
expect(idx.get('operating-manual-for-spaceship-earth')).toBe('2011-11-14');
expect(
idx.get('50-years-of-the-design-science-revolution-and-the-world-game'),
).toBe('1977-01-01');
expect(idx.has('_index')).toBe(false);
expect(idx.size).toBe(2);
});
});
describe('resolveDate', () => {
const idx = new Map([['operating-manual-for-spaceship-earth', '2011-11-14']]);
const gitDateFn = () => '2026-07-04';
it('returns the accession date when the slug matches', () => {
const book = {
slug: 'operating_manual_for_spaceship_earth',
title: 'Operating Manual for Spaceship Earth',
epubDir: 'operating_manual_for_spaceship_earth',
};
expect(resolveDate(book, idx, gitDateFn)).toBe('2011-11-14');
});
it('matches on title when the slug does not', () => {
const byTitle = new Map([['the-design-initiative', '1966-01-01']]);
const book = {
slug: 'wdsd_2_the_design_initiative',
title: 'The Design Initiative',
epubDir: 'world_design_science_decade_1965-1975/document-2-the-design-initiative',
};
expect(resolveDate(book, byTitle, gitDateFn)).toBe('1966-01-01');
});
it('falls back to the git date when nothing matches', () => {
const book = { slug: 'x', title: 'Unknown Book', epubDir: 'x' };
expect(resolveDate(book, idx, gitDateFn)).toBe('2026-07-04');
});
});
describe('bookPostMarkdown', () => {
it('links a readable book to /read/<slug> with an author byline', () => {
const md = bookPostMarkdown({
title: 'Grunch of Giants',
author: 'R. Buckminster Fuller',
slug: 'grunch_of_giants',
date: '2024-04-14',
readable: true,
});
expect(md).toContain('title: "Grunch of Giants"');
expect(md).toContain('date: 2024-04-14');
expect(md).toContain('tags: [library, book]');
expect(md).toContain('by R. Buckminster Fuller');
expect(md).toContain('[Read it →](/read/grunch_of_giants)');
expect(md).not.toContain('/library');
});
it('links a download-only book to /library and omits an absent author', () => {
const md = bookPostMarkdown({
title: 'Some Reference',
slug: 'some_reference',
date: '2025-01-02',
readable: false,
});
expect(md).toContain('[In the library →](/library)');
expect(md).not.toContain('/read/');
expect(md).not.toContain('by ');
expect(md).toContain('summary: "Joined the library."');
});
it('escapes a double quote in the title', () => {
const md = bookPostMarkdown({
title: 'A "Quoted" Title',
slug: 's',
date: '2025-01-01',
readable: false,
});
expect(md).toContain('title: "A \\"Quoted\\" Title"');
});
});
- Step 2: Run the tests to verify they fail
Run: cd apps/reader && pnpm vitest run scripts/lib/chronofilePosts.test.mjs
Expected: FAIL — Failed to resolve import "./chronofilePosts.mjs" / functions not defined.
- Step 3: Write the implementation
Create apps/reader/scripts/lib/chronofilePosts.mjs:
// apps/reader/scripts/lib/chronofilePosts.mjs
// Pure helpers for the book-addition Chronofile generator. No fs/network here so
// these stay unit-testable — the generator (build-book-chronofile.mjs) supplies the
// discovered books, the accession filename list, and a git-date function.
/** Normalize a slug or title to a comparison key: lowercase, non-alphanumeric -> '-', collapsed, trimmed. */
export function normalizeKey(s) {
return String(s)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Build { normalizedTitleSlug -> 'YYYY-MM-DD' } from accession record filenames named
* `<title-slug>--<YYYY-MM-DD>--<accession-id>.md`. Skips `_index.md` and any name
* lacking a valid date segment. `--` is the field separator; single '-' stays in the slug.
* @param {string[]} filenames basenames from .wiki/inventory/items/
* @returns {Map<string,string>}
*/
export function buildAccessionIndex(filenames) {
const index = new Map();
for (const name of filenames) {
if (name === '_index.md' || !name.endsWith('.md')) continue;
const parts = name.slice(0, -3).split('--'); // drop '.md'
if (parts.length < 3) continue; // need title--date--id
const date = parts[parts.length - 2];
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
const key = normalizeKey(parts.slice(0, -2).join('--'));
if (!index.has(key)) index.set(key, date); // first (dir-sorted) wins
}
return index;
}
/**
* Resolve a book's "added" date: accession match on slug, then on title, else the
* git first-add date. Returns 'YYYY-MM-DD' or whatever gitDateFn returns (may be null).
* @param {{slug:string,title:string,epubDir:string}} book
* @param {Map<string,string>} accessionIndex
* @param {(epubDir:string)=>string|null} gitDateFn
*/
export function resolveDate(book, accessionIndex, gitDateFn) {
return (
accessionIndex.get(normalizeKey(book.slug)) ??
accessionIndex.get(normalizeKey(book.title)) ??
gitDateFn(book.epubDir)
);
}
/** Escape a value for a double-quoted YAML scalar. */
function yamlStr(s) {
return `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
/**
* Render one book-addition Chronofile post (frontmatter + short body).
* @param {{title:string,author?:string,slug:string,date:string,readable:boolean}} b
* @returns {string}
*/
export function bookPostMarkdown(b) {
const summary = b.author ? `Joined the library — ${b.author}.` : 'Joined the library.';
const byline = b.author ? `, by ${b.author},` : '';
const link = b.readable
? `[Read it →](/read/${b.slug})`
: `[In the library →](/library)`;
return [
'---',
`title: ${yamlStr(b.title)}`,
`date: ${b.date}`,
`summary: ${yamlStr(summary)}`,
'tags: [library, book]',
'---',
'',
`**${b.title}**${byline} is now in the Buckyverse library.`,
'',
link,
'',
].join('\n');
}
- Step 4: Run the tests to verify they pass
Run: cd apps/reader && pnpm vitest run scripts/lib/chronofilePosts.test.mjs
Expected: PASS — all specs green.
- Step 5: Commit
git add apps/reader/scripts/lib/chronofilePosts.mjs apps/reader/scripts/lib/chronofilePosts.test.mjs
git commit -m "feat(chronofile): pure lib for book-addition posts (normalize/index/date/markdown)"
Task 2: The generator — discover catalog books, resolve dates, emit posts
Files:
-
Create:
apps/reader/scripts/build-book-chronofile.mjs -
Modify:
apps/reader/package.json(addchronofile-booksscript) -
Step 1: Write the generator
Create apps/reader/scripts/build-book-chronofile.mjs:
// apps/reader/scripts/build-book-chronofile.mjs
// Emit one Chronofile post per catalog book — a running accession log. Mirrors the
// reader's catalog discovery (src/loaders/catalogLoader.ts) so the post set matches
// the live library. Each book's date comes from an accession record
// (.wiki/inventory/items/) when one matches, else the git first-add date of the
// book's dir. Idempotent: clears src/content/chronofile/books/ first, so a book that
// leaves the catalog loses its post. Self-contained: system `unzip` + `git`.
//
// Run from apps/reader: `pnpm chronofile-books`. Commit the generated files.
import { execFileSync } from 'node:child_process';
import { readFileSync, readdirSync, existsSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import {
buildAccessionIndex,
resolveDate,
bookPostMarkdown,
} from './lib/chronofilePosts.mjs';
const READER = process.cwd();
const REPO = path.resolve(READER, '../..');
const ITEMS = path.join(REPO, '.wiki/inventory/items');
const OUT_DIR = path.join(READER, 'src/content/chronofile/books');
const EXCLUDED_SLUGS = new Set(['test_book']); // matches catalogLoader
/** Parse books.config.ts (can't import TS from node) -> readable books. */
function readConfigBooks() {
const src = readFileSync(path.join(READER, 'books.config.ts'), 'utf8');
const books = [];
const re = /\{\s*slug:\s*'([^']+)',\s*epubPath:\s*'([^']+)'/g;
for (const m of src.matchAll(re)) books.push({ slug: m[1], epubPath: m[2] });
return books;
}
/** Mirror catalogLoader.discover(): repo-root <dir>/*.epub deduped, plus deeper books.config epubs. */
function discover() {
const configBooks = readConfigBooks();
const readable = new Set(configBooks.map((b) => b.slug));
const byDir = new Map(); // slug -> abs epub path
for (const dir of readdirSync(REPO, { withFileTypes: true })) {
if (!dir.isDirectory() || dir.name.startsWith('.') || dir.name === 'apps') continue;
const slug = dir.name;
if (EXCLUDED_SLUGS.has(slug)) continue;
const epubs = readdirSync(path.join(REPO, slug))
.filter((f) => f.endsWith('.epub') && !f.endsWith('.epub.backup'));
if (epubs.length === 0) continue;
const preferred = `${slug}.epub`;
const chosen = epubs.includes(preferred) ? preferred : epubs.sort()[0];
byDir.set(slug, path.join(REPO, slug, chosen));
}
// Merge books.config entries whose EPUB lives deeper than one level (e.g. WDSD).
for (const b of configBooks) {
if (byDir.has(b.slug) || EXCLUDED_SLUGS.has(b.slug)) continue;
const abs = path.resolve(READER, b.epubPath);
if (existsSync(abs)) byDir.set(b.slug, abs);
}
return [...byDir.entries()]
.map(([slug, epub]) => ({
slug,
epub,
epubDir: path.relative(REPO, path.dirname(epub)),
readable: readable.has(slug),
}))
.sort((a, b) => a.slug.localeCompare(b.slug));
}
const ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'" };
function decode(s) {
return s.replace(/&|<|>|"|'|'/g, (m) => ENTITIES[m]).trim();
}
/** dc:title / dc:creator from the EPUB's OPF (same metadata the catalog reads). */
function epubMeta(epub, slug) {
let title = slug.replace(/_/g, ' ');
let author;
try {
const opf = execFileSync('unzip', ['-Z1', epub], { encoding: 'utf8' })
.split('\n')
.find((f) => /\.opf$/i.test(f));
if (opf) {
const xml = execFileSync('unzip', ['-p', epub, opf], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
const t = xml.match(/<dc:title[^>]*>([^<]+)<\/dc:title>/i);
const c = xml.match(/<dc:creator[^>]*>([^<]+)<\/dc:creator>/i);
if (t) title = decode(t[1]);
if (c) author = decode(c[1]);
}
} catch {
// fall back to the humanized slug title, no author
}
return { title, author };
}
/** git first-add date (earliest = last line) of the book's dir. Null if untracked. */
function gitAddDate(epubDir) {
try {
const out = execFileSync(
'git',
['log', '--diff-filter=A', '--format=%ad', '--date=short', '--', epubDir],
{ cwd: REPO, encoding: 'utf8' },
)
.trim()
.split('\n')
.filter(Boolean);
return out.length ? out[out.length - 1] : null;
} catch {
return null;
}
}
// --- run ---
const accessionIndex = buildAccessionIndex(existsSync(ITEMS) ? readdirSync(ITEMS) : []);
const books = discover();
rmSync(OUT_DIR, { recursive: true, force: true }); // idempotent: books/ reflects the current catalog exactly
mkdirSync(OUT_DIR, { recursive: true });
let unresolved = 0;
for (const b of books) {
const { title, author } = epubMeta(b.epub, b.slug);
let date = resolveDate({ slug: b.slug, title, epubDir: b.epubDir }, accessionIndex, gitAddDate);
if (!date) {
unresolved++;
console.warn(` no accession/git date for ${b.slug} (${b.epubDir}) — omitting post`);
continue; // never fabricate a date; skip the rare untracked book
}
const md = bookPostMarkdown({ title, author, slug: b.slug, date, readable: b.readable });
writeFileSync(path.join(OUT_DIR, `${date}-${b.slug}.md`), md);
}
console.log(
`chronofile-books: ${books.length - unresolved} posts (${books.length} books, ${unresolved} skipped) -> ${path.relative(READER, OUT_DIR)}/`,
);
- Step 2: Add the npm script
Modify apps/reader/package.json — add to "scripts" (next to the other generators, before "redirects"):
"chronofile-books": "node scripts/build-book-chronofile.mjs",
- Step 3: Run the generator
Run: cd apps/reader && pnpm chronofile-books
Expected: prints e.g. chronofile-books: N posts (N books, 0 skipped) -> src/content/chronofile/books/ and creates src/content/chronofile/books/<date>-<slug>.md files. Confirm known books resolved to accession dates:
grep -l '^date: 2011-11-14' apps/reader/src/content/chronofile/books/*operating_manual* # accession date, not git
ls apps/reader/src/content/chronofile/books/ | wc -l # ~80 files
- Step 4: Verify idempotency (inaugural untouched, stable output)
Run:
git -C /Volumes/Storage/belmendo/Projects/buckyverse.org status --porcelain apps/reader/src/content/chronofile/2026-07-06-set-free-as-pattern.md
Expected: empty output (the hand-written inaugural is unmodified). Then re-run the generator and confirm no new diff:
cd apps/reader && pnpm chronofile-books && git -C /Volumes/Storage/belmendo/Projects/buckyverse.org status --porcelain apps/reader/src/content/chronofile/books/ | head
Expected: identical file set on a second run (no spurious changes for already-committed books).
- Step 5: Commit
git add apps/reader/scripts/build-book-chronofile.mjs apps/reader/package.json apps/reader/src/content/chronofile/books/
git commit -m "feat(chronofile): generate one accession post per catalog book"
Task 3: Rest route + list tag chips
Files:
-
Rename:
apps/reader/src/pages/chronofile/[slug].astro→apps/reader/src/pages/chronofile/[...slug].astro -
Modify:
apps/reader/src/pages/chronofile/index.astro -
Step 1: Rename the post route to a rest route
The nested book-post ids contain a slash (books/<date>-<slug>), which a single [slug] param cannot hold. Rename the file so the param captures the full slashed id:
git mv apps/reader/src/pages/chronofile/[slug].astro "apps/reader/src/pages/chronofile/[...slug].astro"
The file contents are unchanged — getStaticPaths already sets params: { slug: post.id }, which Astro maps onto the [...slug] rest param for both slashed (books/2024-…-grunch_of_giants) and flat (2026-07-06-set-free-as-pattern) ids.
- Step 2: Add tag chips to the list page
Modify apps/reader/src/pages/chronofile/index.astro. In the <li class="chrono-item"> block, add a chips row after the summary. Replace:
{p.data.summary && <p class="chrono-summary">{p.data.summary}</p>}
</li>
with:
{p.data.summary && <p class="chrono-summary">{p.data.summary}</p>}
{p.data.tags && p.data.tags.length > 0 && (
<ul class="chrono-tags">
{p.data.tags.map((t) => <li>{t}</li>)}
</ul>
)}
</li>
Then add these rules to the <style> block (before the closing </style>):
.chrono-tags { list-style: none; display: flex; flex-wrap: wrap; gap: .4rem; padding: .35rem 0 0; margin: 0; }
.chrono-tags li {
font-size: .72rem; color: var(--muted, #666); background: var(--chip, #efece6);
border-radius: 999px; padding: .1rem .55rem; letter-spacing: .02em;
}
- Step 3: Build to verify routing + rendering
Run: cd apps/reader && pnpm build
Expected: build succeeds; the content collection ingests books/*.md without schema errors.
- Step 4: Verify the generated HTML
Run:
cd apps/reader
ls dist/chronofile/books/ | head # nested post dirs exist
test -f dist/chronofile/2026-07-06-set-free-as-pattern/index.html && echo "inaugural OK"
grep -rl 'chrono-tags' dist/chronofile/index.html # chips rendered on the list
# a known readable book post links to /read/<slug>:
grep -rl '/read/grunch_of_giants' dist/chronofile/books/*grunch_of_giants*/index.html
Expected: nested book-post pages present, inaugural page present, chips on the list, readable book links to /read/….
- Step 5: Commit
git add "apps/reader/src/pages/chronofile/[...slug].astro" apps/reader/src/pages/chronofile/index.astro
git commit -m "feat(chronofile): rest route for nested book posts + list tag chips"
Task 4: Full build, deploy, and live verification
Files: none (deploy step)
- Step 1: Run the full test suite
Run: cd apps/reader && pnpm test
Expected: PASS — including scripts/lib/chronofilePosts.test.mjs and the existing suites.
- Step 2: Regenerate + build clean
Run:
cd apps/reader && pnpm chronofile-books && pnpm build
Expected: generator prints 0 skipped; build succeeds (Astro + Pagefind). If any book is skipped, note it (untracked/no accession) — acceptable, but investigate before deploying.
- Step 3: Deploy to future.buckyverse.org
Run: cd apps/reader && pnpm deploy
Expected: wrangler pages deploy dist … uploads and reports a deployment URL.
- Step 4: Live verification
Run:
curl -s -o /dev/null -w '%{http_code}\n' https://future.buckyverse.org/chronofile/
# a known book post (adjust the date to whatever the generator resolved for grunch):
d=$(basename apps/reader/src/content/chronofile/books/*grunch_of_giants*.md .md)
curl -s -o /dev/null -w '%{http_code}\n' "https://future.buckyverse.org/chronofile/books/$d"
curl -s https://future.buckyverse.org/chronofile/ | grep -c 'chrono-item' # inaugural + ~80 book posts
Expected: /chronofile/ → 200; the book post → 200; the list shows many items.
- Step 5: Commit any regenerated posts
If Step 2 changed generated files, commit them:
git add apps/reader/src/content/chronofile/books/
git commit -m "chore(chronofile): regenerate book posts" --allow-empty
(Skip if there was no diff.)
Self-review checklist (run before executing)
- Spec coverage: generator + npm script (Task 2), ~80 generated posts (Task 2 Step 3), accession-then-git date (Task 1
resolveDate, Task 2gitAddDate), readable→/readelse/library(Task 1bookPostMarkdown), idempotentbooks/-only clear (Task 2 Step 1/4),[...slug]rename (Task 3 Step 1), list chips (Task 3 Step 2), unit tests (Task 1), build/deploy/live checks (Tasks 3–4). ✔ All spec §6 in-scope items mapped. - Out of scope (spec §6), correctly absent: tag-filter UI, RSS, cover enrichment, changes to the inaugural, changes to how books are added. ✔
- Type consistency:
bookobjects carry{slug,title,epubDir,readable};resolveDatereadsslug/title/epubDir;bookPostMarkdownreads{title,author?,slug,date,readable}; generator builds both shapes fromdiscover()+epubMeta(). Names match across tasks. ✔ - No placeholders: every code step is complete; no TBD/TODO. ✔
- Date safety: the generator never fabricates a date — a book with neither an accession match nor a git add date is skipped with a warning (Task 2), keeping committed output deterministic.