BUCKYVERSE

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


File structure


Task 1: Pure lib — normalization, accession index, date resolution, post markdown

Files:

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"');
  });
});

Run: cd apps/reader && pnpm vitest run scripts/lib/chronofilePosts.test.mjs
Expected: FAIL — Failed to resolve import "./chronofilePosts.mjs" / functions not defined.

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');
}

Run: cd apps/reader && pnpm vitest run scripts/lib/chronofilePosts.test.mjs
Expected: PASS — all specs green.

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:

// 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 = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&apos;': "'" };
function decode(s) {
  return s.replace(/&amp;|&lt;|&gt;|&quot;|&#39;|&apos;/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)}/`,
);

Modify apps/reader/package.json — add to "scripts" (next to the other generators, before "redirects"):

    "chronofile-books": "node scripts/build-book-chronofile.mjs",

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

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).

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:

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.

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;
  }

Run: cd apps/reader && pnpm build
Expected: build succeeds; the content collection ingests books/*.md without schema errors.

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/….

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)

Run: cd apps/reader && pnpm test
Expected: PASS — including scripts/lib/chronofilePosts.test.mjs and the existing suites.

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.

Run: cd apps/reader && pnpm deploy
Expected: wrangler pages deploy dist … uploads and reports a deployment URL.

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.

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)