# Pipeline Refactor Implementation Plan

> **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:** Extract a shared `finalizeHtml` post-processing step, add a staleness check to `composeItem`, and make `composeSchedule` a pure orchestrator that delegates per-topic work to `composeItem`.

**Architecture:** `finalizeHtml` is extracted as a named export in `src/item.js` and wraps `renderNotes → injectOutline → injectTransition` in one place. `composeItem` gains an early-return staleness check (skip if `index.html` is newer than the source file). `composeSchedule` becomes async, calls `composeItem` per topic (building standalone decks), then runs Marp on the combined markdown and calls `finalizeHtml`. Asset copying is removed from `composeSchedule` — it already happens inside `composeItem`.

**Tech Stack:** Node.js ESM, `node:fs`, `node:path`, `node:test` + `node:assert/strict` for tests.

---

## Design spec

Full design: `docs/claude/superpowers/specs/2026-05-09-pipeline-refactor.md`

Key constraint: `buildSchedule` stays **synchronous**. Only `composeSchedule` becomes async.

`composeSchedule` pre-scans the schedule for topic markers (re-using `expandScheduleIncludes` to handle nested schedules), calls `composeItem` per topic, and builds `topicDirs` itself. `buildSchedule` is then called only for `frontmatter`, `markdown`, and `outline` — its `topicDirs` return value is discarded. TBR activities (`c{N}-slug`) are excluded from `composeItem` calls because `resolveSlug` returns `type: 'tbr'` for them.

---

## File map

| File | Change |
| --- | --- |
| `src/item.js` | Add staleness check before `buildConfig`; move `outPath` computation early; add `injectTransition` import; extract `finalizeHtml` as named export; update `composeItem` to call it |
| `src/schedule.js` | Change `expandContentMarker` to read topic body from `index-built.md` when available (fallback to source); make `composeSchedule` async; add pre-scan that calls `composeItem` per topic before `buildSchedule`; remove `copyFilesFlat` loop; replace `renderNotes` + `injectOutline` with `finalizeHtml`; remove now-unused imports |
| `bin/compose.js` | Add `await` to `composeSchedule(...)` call |
| `src/preview.js` | Add `await` to both `composeSchedule(...)` calls; make `recompose` async; fix debounce handler |
| `test/item.test.js` | Create: unit tests for `finalizeHtml` and staleness check |

---

## Task 1: Extract `finalizeHtml` and wire `injectTransition`

**Files:**

- Modify: `src/item.js`
- Create: `test/item.test.js`

- [ ] **Step 1: Write the failing tests**

Create `test/item.test.js`:

```js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { finalizeHtml } from '../src/item.js'

function scratch() {
  mkdirSync('tmp', { recursive: true })
  return mkdtempSync(join('tmp', 'item-test-'))
}

test('finalizeHtml embeds outline JSON in a script tag in <head>', () => {
  const dir = scratch()
  try {
    const html = '<html><head></head><body></body></html>'
    const outline = { slug: 'my-deck', slide_count: 3, title: 'My Deck' }
    const result = finalizeHtml(html, { root: dir, expanded: '', outline })
    assert.match(result, /<script type="application\/json" id="deck-outline">/)
    assert.match(result, /"slug":"my-deck"/)
  } finally { rmSync(dir, { recursive: true, force: true }) }
})

test('finalizeHtml does not inject a transition script when html has no data-transition', () => {
  const dir = scratch()
  try {
    const html = '<html><head></head><body></body></html>'
    const outline = { slug: 'my-deck', slide_count: 1, title: 'My Deck' }
    const result = finalizeHtml(html, { root: dir, expanded: '', outline })
    // injectTransition is a no-op when there are no data-transition attributes
    assert.ok(!result.includes('window.__transitions'), 'nav.js should not be injected')
  } finally { rmSync(dir, { recursive: true, force: true }) }
})
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
node --test test/item.test.js
```

Expected: `ReferenceError: finalizeHtml is not exported` (or similar import error).

- [ ] **Step 3: Implement `finalizeHtml` and update `composeItem`**

In `src/item.js`:

Add `injectTransition` to imports (new line after existing imports):

```js
import { injectTransition } from './inject-transition.js'
```

Add `finalizeHtml` as a named export, immediately before `composeItem`:

```js
export function finalizeHtml(html, { root, expanded, outline }) {
  return injectTransition(
    injectOutline(
      renderNotes(html, expanded),
      outline
    ),
    { contentRoot: root }
  )
}
```

In `composeItem`, replace lines 50–52 (the current HTML finalization):

Before:

```js
  const outPath = resolve(destDir, 'index.html')
  const html = readFileSync(outPath, 'utf8')
  writeFileSync(outPath, injectOutline(renderNotes(html, expanded), outline))
```

After (keep `outPath` in place for now — it moves in Task 2):

```js
  const outPath = resolve(destDir, 'index.html')
  const html = readFileSync(outPath, 'utf8')
  writeFileSync(outPath, finalizeHtml(html, { root, expanded, outline }))
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
node --test test/item.test.js
```

Expected: all 2 tests pass.

- [ ] **Step 5: Run the full test suite**

```bash
pnpm test
```

Expected: all tests pass. If `inject-transition.test.js` or others fail, debug before committing.

- [ ] **Step 6: Lint**

```bash
pnpm lint
```

Expected: no errors.

- [ ] **Step 7: Commit**

```bash
git add src/item.js test/item.test.js
git commit -m "extract finalizeHtml from composeItem, wire injectTransition"
```

---

## Task 2: Add staleness check to `composeItem`

**Files:**

- Modify: `src/item.js`
- Modify: `test/item.test.js`

The staleness check must go **before** `buildConfig` so that a fresh item costs nothing at all. `outPath` must be computable before the check — it only depends on `destDir`, which is pure path computation.

- [ ] **Step 1: Write the failing staleness test**

Append to `test/item.test.js`:

```js
import { writeFileSync, utimesSync } from 'node:fs'
import { resolve } from 'node:path'
import { composeItem } from '../src/item.js'

test('composeItem returns 0 immediately when index.html is newer than source', async () => {
  const root = scratch()
  try {
    // minimal topic source
    mkdirSync(resolve(root, 'content/topics/fresh'), { recursive: true })
    writeFileSync(
      resolve(root, 'content/topics/fresh/topic.md'),
      '---\ntitle: Fresh\n---\n\n# Fresh\n'
    )

    // pre-built index.html with a far-future mtime
    mkdirSync(resolve(root, 'decks/topics/fresh'), { recursive: true })
    writeFileSync(resolve(root, 'decks/topics/fresh/index.html'), '<html>cached</html>')
    const future = new Date(Date.now() + 60_000)
    utimesSync(resolve(root, 'decks/topics/fresh/index.html'), future, future)

    // capture stdout to verify nothing was built
    const lines = []
    const orig = process.stdout.write.bind(process.stdout)
    process.stdout.write = (s) => { lines.push(String(s)); return true }

    let code
    try {
      code = await composeItem('fresh', [], { root })
    } finally {
      process.stdout.write = orig
    }

    assert.equal(code, 0, 'should return 0 for a fresh item')
    assert.ok(!lines.some(s => s.startsWith('→')), 'should not have written an output path')
  } finally { rmSync(root, { recursive: true, force: true }) }
})
```

- [ ] **Step 2: Run the test to verify it fails**

```bash
node --test test/item.test.js
```

Expected: the new staleness test fails (composeItem tries to build the item instead of returning early, or errors because the Marp binary isn't available in the tmp root).

- [ ] **Step 3: Implement the staleness check**

In `src/item.js`, add `existsSync` and `statSync` to the `node:fs` import:

```js
import { rmSync, mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from 'node:fs'
```

Then, after the four path computation lines (after `const config = buildConfig(root)` is **not** yet reached — the staleness check goes before it), restructure the top of `composeItem` as follows.

Current top of function (lines 21–29):

```js
  const resolved = resolveSlug(slug, { root })
  const srcDir = dirname(resolved.path)
  const rel = relative(resolve(root, 'content'), srcDir)
  const destDir = resolve(root, 'decks', rel)
  const marpBin = resolve(_pkgDir, 'node_modules/.bin/marp')
  const config = buildConfig(root)

  rmSync(destDir, { recursive: true, force: true })
  mkdirSync(destDir, { recursive: true })
```

Replace with:

```js
  const resolved = resolveSlug(slug, { root })
  const srcDir = dirname(resolved.path)
  const rel = relative(resolve(root, 'content'), srcDir)
  const destDir = resolve(root, 'decks', rel)
  const outPath = resolve(destDir, 'index.html')

  if (existsSync(outPath) && statSync(outPath).mtimeMs >= statSync(resolved.path).mtimeMs) {
    return 0
  }

  const marpBin = resolve(_pkgDir, 'node_modules/.bin/marp')
  const config = buildConfig(root)

  rmSync(destDir, { recursive: true, force: true })
  mkdirSync(destDir, { recursive: true })
```

Then remove the now-duplicate `const outPath = resolve(destDir, 'index.html')` that currently appears further down in the function (before `const html = readFileSync(outPath, 'utf8')`).

After removing the duplicate, the finalization tail of the function should look like:

```js
  const html = readFileSync(outPath, 'utf8')
  writeFileSync(outPath, finalizeHtml(html, { root, expanded, outline }))
  process.stdout.write(`→ ${relative(root, outPath)}\n`)

  return 0
```

- [ ] **Step 4: Run the tests to verify they pass**

```bash
node --test test/item.test.js
```

Expected: all 3 tests pass (2 from Task 1, 1 new staleness test).

- [ ] **Step 5: Run the full test suite**

```bash
pnpm test
```

Expected: all tests pass.

- [ ] **Step 6: Lint**

```bash
pnpm lint
```

Expected: no errors.

- [ ] **Step 7: Commit**

```bash
git add src/item.js test/item.test.js
git commit -m "add staleness check to composeItem — skip rebuild when index.html is fresh"
```

---

## Task 3: Refactor `composeSchedule` and `expandContentMarker`

**Files:**

- Modify: `src/schedule.js`
- Modify: `bin/compose.js`
- Modify: `src/preview.js`

Two changes in `schedule.js`:

1. **`expandContentMarker`**: for topics, read body from `decks/topics/<slug>/index-built.md` when it exists, falling back to the raw source. This gives schedules `expandBuildMarkers` and `expandMorphSpans` for free. `topicDir` stays as the source dir (used by `preview.js` watchers).

2. **`composeSchedule`**: pre-scan the schedule for topic markers and call `composeItem` per slug before `buildSchedule` runs — so `index-built.md` exists by the time `expandContentMarker` reads it. Then remove `copyFilesFlat` and use `finalizeHtml`.

Existing schedule tests call `buildSchedule` only. In test fixtures `index-built.md` never exists, so the fallback to source applies — all tests pass unchanged.

### 3a: Refactor `src/schedule.js`

- [ ] **Step 1: Update imports**

Current `schedule.js` import block (lines 1–19):

```js
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { join, resolve, relative, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

const _pkgDir = dirname(dirname(fileURLToPath(import.meta.url)))

import { buildConfig } from './config.js'
import { splitFrontmatter, parseFrontmatter } from './frontmatter.js'
import { findMarkers } from './marker.js'
import { resolveSlug } from './resolve.js'
import { collapseBreaks, maskCode } from './slides.js'
import { copyFilesFlat } from './assets.js'
import { firstHeading } from './heading.js'
import { renderNotes } from './render-notes.js'
import { injectOutline } from './inject-outline.js'
import * as sort from './sort.js'
import { parseTimings, parseStartTime, validateTimings } from './timings.js'
import { buildDays, slideRangesFromSentinels } from './schedule-days.js'
```

Replace with:

```js
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, statSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { join, resolve, relative, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

const _pkgDir = dirname(dirname(fileURLToPath(import.meta.url)))

import { buildConfig } from './config.js'
import { splitFrontmatter, parseFrontmatter } from './frontmatter.js'
import { findMarkers } from './marker.js'
import { resolveSlug } from './resolve.js'
import { collapseBreaks, maskCode } from './slides.js'
import { firstHeading } from './heading.js'
import { composeItem, finalizeHtml } from './item.js'
import * as sort from './sort.js'
import { parseTimings, parseStartTime, validateTimings } from './timings.js'
import { buildDays, slideRangesFromSentinels } from './schedule-days.js'
```

Changes:

- Add `statSync` to `node:fs` (needed for schedule staleness check)
- Remove `copyFilesFlat` import (no asset copying in schedule)
- Remove `renderNotes` import (now inside `finalizeHtml`)
- Remove `injectOutline` import (now inside `finalizeHtml`)
- Add `composeItem, finalizeHtml` from `./item.js`

- [ ] **Step 2: Update `expandContentMarker` to read from `index-built.md`**

Current `expandContentMarker` body (lines 96–136), specifically the `try` block (lines 103–113):

```js
  try {
    const resolved = resolveSlug(parsed.slug, { root })
    resolvedType = resolved.type
    const split = splitFrontmatter(readFileSync(resolved.path, 'utf8'))
    contentBody = split.body
    const itemFm = parseFrontmatter(split.frontmatter)
    title = itemFm.title ?? firstHeading(contentBody) ?? parsed.slug
    if (resolvedType === 'topic') topicDir = dirname(resolved.path)
  } catch (err) {
```

Replace with:

```js
  try {
    const resolved = resolveSlug(parsed.slug, { root })
    resolvedType = resolved.type
    const srcDir = dirname(resolved.path)
    let srcPath = resolved.path
    if (resolvedType === 'topic') {
      topicDir = srcDir
      const rel = relative(resolve(root, 'content'), srcDir)
      const builtPath = resolve(root, 'decks', rel, 'index-built.md')
      if (existsSync(builtPath)) srcPath = builtPath
    }
    const split = splitFrontmatter(readFileSync(srcPath, 'utf8'))
    contentBody = split.body
    const itemFm = parseFrontmatter(split.frontmatter)
    title = itemFm.title ?? firstHeading(contentBody) ?? parsed.slug
  } catch (err) {
```

Key points:

- `topicDir` is set to `srcDir` (the source dir) — stays as the source dir for `preview.js` file watchers
- For topics, we compute the built path (`decks/<rel>/index-built.md`) and read from there if it exists
- When `index-built.md` doesn't exist (test fixtures, first build of a standalone topic), it falls back to the raw source

- [ ] **Step 3: Rewrite `composeSchedule`**

Current `composeSchedule` (lines 226–254):

```js
export function composeSchedule(slug, _rest = [], { root = process.cwd(), draft: isDraft = false, deckMeta = {} } = {}) {
  const { frontmatter, markdown, outline, topicDirs } = buildSchedule(slug, { root, draft: isDraft, deckMeta })

  const destDir = resolve(root, `decks/schedules/${slug}`)
  rmSync(destDir, { recursive: true, force: true })
  mkdirSync(destDir, { recursive: true })

  for (const topicDir of topicDirs) {
    copyFilesFlat(topicDir, destDir, { exclude: ['index.md'] })
  }

  const composedPath = join(destDir, 'schedule.md')
  writeFileSync(composedPath, `---\n${frontmatter}\n---\n\n${markdown}`)

  const marpBin = resolve(_pkgDir, 'node_modules/.bin/marp')
  const marpConfig = buildConfig(root)
  const marpRun = spawnSync(marpBin, ['--no-stdin', '--config', marpConfig, '--output', 'index.html', 'schedule.md'], {
    cwd: destDir,
    stdio: 'inherit',
  })
  if (marpRun.status !== 0) return { status: marpRun.status ?? 1, topicDirs }

  const outPath = join(destDir, 'index.html')
  const html = readFileSync(outPath, 'utf8')
  writeFileSync(outPath, injectOutline(renderNotes(html, markdown), outline))
  process.stdout.write(`→ ${relative(root, outPath)}\n`)

  return { status: 0, topicDirs }
}
```

Replace with:

```js
export async function composeSchedule(slug, _rest = [], { root = process.cwd(), draft: isDraft = false, deckMeta = {} } = {}) {
  const schedSrc = resolve(root, `content/schedules/${slug}/index.md`)
  if (!existsSync(schedSrc)) throw new Error(`schedule not found: ${slug}`)
  const { body: rawBody } = splitFrontmatter(readFileSync(schedSrc, 'utf8'))
  const { expandedBody } = expandScheduleIncludes(rawBody, root, new Set([slug]))

  // Call composeItem per topic. Each has its own staleness check so this is fast
  // when items are up to date. Also collects topic source dirs for file watchers.
  const topicDirs = new Set()
  for (const marker of findMarkers(expandedBody)) {
    if (marker.parsed.kind !== 'content') continue
    let resolved
    try {
      resolved = resolveSlug(marker.parsed.slug, { root })
    } catch (err) {
      if (!isDraft) throw err
      continue
    }
    if (resolved.type !== 'topic') continue
    topicDirs.add(dirname(resolved.path))
    const code = await composeItem(marker.parsed.slug, [], { root })
    if (code !== 0) throw new Error(`topic build failed: ${marker.parsed.slug} (exit ${code})`)
  }

  // Staleness check for the schedule itself: skip Marp if the schedule output is
  // newer than its source AND no topic has been rebuilt since (no topic index.html
  // is newer than the schedule index.html).
  const schedOut = resolve(root, `decks/schedules/${slug}/index.html`)
  if (existsSync(schedOut)) {
    const schedOutMtime = statSync(schedOut).mtimeMs
    if (schedOutMtime >= statSync(schedSrc).mtimeMs) {
      const allTopicsFresh = [...topicDirs].every(srcDir => {
        const rel = relative(resolve(root, 'content'), srcDir)
        const topicOut = resolve(root, 'decks', rel, 'index.html')
        return !existsSync(topicOut) || statSync(topicOut).mtimeMs <= schedOutMtime
      })
      if (allTopicsFresh) return { status: 0, topicDirs }
    }
  }

  const { frontmatter, markdown, outline } = buildSchedule(slug, { root, draft: isDraft, deckMeta })

  const destDir = resolve(root, `decks/schedules/${slug}`)
  rmSync(destDir, { recursive: true, force: true })
  mkdirSync(destDir, { recursive: true })

  const composedPath = join(destDir, 'schedule.md')
  writeFileSync(composedPath, `---\n${frontmatter}\n---\n\n${markdown}`)

  const marpBin = resolve(_pkgDir, 'node_modules/.bin/marp')
  const marpConfig = buildConfig(root)
  const marpRun = spawnSync(marpBin, ['--no-stdin', '--config', marpConfig, '--output', 'index.html', 'schedule.md'], {
    cwd: destDir,
    stdio: 'inherit',
  })
  if (marpRun.status !== 0) return { status: marpRun.status ?? 1, topicDirs }

  const outPath = join(destDir, 'index.html')
  const html = readFileSync(outPath, 'utf8')
  writeFileSync(outPath, finalizeHtml(html, { root, expanded: markdown, outline }))
  process.stdout.write(`→ ${relative(root, outPath)}\n`)

  return { status: 0, topicDirs }
}
```

Changes:

- `export function` → `export async function`
- Pre-scan: re-uses the private `expandScheduleIncludes` (same file) to expand nested schedule markers, calls `composeItem` per topic — each with its own staleness check so it's fast when topics are up to date
- `topicDirs` is computed here (not from `buildSchedule`) so it's available for the early return to `preview.js` watchers
- **Cascading staleness check**: after all `composeItem` calls, skip the schedule Marp run if: (a) schedule `index.html` is newer than schedule `index.md`, AND (b) no topic `index.html` is newer than the schedule `index.html`. If a topic was rebuilt, (b) fails and the schedule rebuilds.
- Remove `copyFilesFlat` loop entirely (assets are in each topic's `decks/topics/<slug>/`)
- `buildSchedule` now returns only `{ frontmatter, markdown, outline }` (we discard its `topicDirs`)
- Replace `injectOutline(renderNotes(html, markdown), outline)` with `finalizeHtml(html, { root, expanded: markdown, outline })`

- [ ] **Step 4: Run the existing test suite**

```bash
pnpm test
```

Expected: all tests pass. The schedule tests call `buildSchedule` directly and never produce `index-built.md`, so `expandContentMarker` falls back to the raw source — no change in behavior for tests.

- [ ] **Step 5: Lint**

```bash
pnpm lint
```

Expected: no errors. If lint flags unused imports, remove them.

### 3b: Update `bin/compose.js`

- [ ] **Step 5: Add `await` to the `composeSchedule` call**

In `bin/compose.js`, the call is inside `async function main()` — it just needs `await`:

Current (line ~60):

```js
        const { status } = composeSchedule(slug, [], { draft: values.draft, deckMeta })
```

Replace with:

```js
        const { status } = await composeSchedule(slug, [], { draft: values.draft, deckMeta })
```

### 3c: Update `src/preview.js`

There are two `composeSchedule` calls in `previewSchedule` and the `recompose` helper must become async.

- [ ] **Step 6: Make `recompose` async and add `await` to both calls**

Current `recompose` (lines ~175–181):

```js
  function recompose() {
    closeWatchers()
    const { status, topicDirs } = composeSchedule(slug, [], { root, draft: isDraft, deckMeta })
    if (status !== 0) { console.error(`\nRecompose failed (exit ${status})\n`); return }
    openWatchers(topicDirs, handler)
    notify()
  }
```

Replace with:

```js
  async function recompose() {
    closeWatchers()
    const { status, topicDirs } = await composeSchedule(slug, [], { root, draft: isDraft, deckMeta })
    if (status !== 0) { console.error(`\nRecompose failed (exit ${status})\n`); return }
    openWatchers(topicDirs, handler)
    notify()
  }
```

Current debounce handler (lines ~183–190):

```js
  let debounce
  function handler() {
    clearTimeout(debounce)
    debounce = setTimeout(() => {
      try { recompose() }
      catch (err) { console.error(`\nRecompose failed: ${err.message}\n`) }
    }, 150)
  }
```

Replace with:

```js
  let debounce
  function handler() {
    clearTimeout(debounce)
    debounce = setTimeout(async () => {
      try { await recompose() }
      catch (err) { console.error(`\nRecompose failed: ${err.message}\n`) }
    }, 150)
  }
```

Current initial compose call in `previewSchedule` (line ~193):

```js
  const { status, topicDirs } = composeSchedule(slug, [], { root, draft: isDraft, deckMeta })
```

Replace with:

```js
  const { status, topicDirs } = await composeSchedule(slug, [], { root, draft: isDraft, deckMeta })
```

- [ ] **Step 7: Run the full test suite again**

```bash
pnpm test
```

Expected: all tests pass.

- [ ] **Step 8: Lint**

```bash
pnpm lint
```

Expected: no errors.

- [ ] **Step 9: Commit**

```bash
git add src/schedule.js bin/compose.js src/preview.js
git commit -m "refactor composeSchedule: delegate to composeItem per topic, use finalizeHtml"
```

---

## Verification

After all three tasks:

1. `pnpm test` — all existing tests still pass
2. Manual smoke test (first build): `pnpm compose schedule <slug>` — should build topic decks into `decks/topics/<slug>/`, then build the combined schedule into `decks/schedules/<slug>/index.html`
3. Run the same command again immediately — full cascade is fresh: no `→ decks/topics/...` lines (topics skipped), no `→ decks/schedules/...` line (schedule skipped)
4. Touch one topic source file (`touch content/topics/<slug>/topic.md`) and run again — that topic rebuilds (`→ decks/topics/<slug>/index.html`), and because its `index.html` is now newer than the schedule's `index.html`, the schedule also rebuilds (`→ decks/schedules/<slug>/index.html`)
5. Run again without changes — everything skipped
6. Verify `injectTransition` is active: check that `data-transition` slides in the output HTML have a `<script>` block before `</body>`

---

## Known gaps / non-goals

- TBR activities (`c{N}-slug`) are not built by `composeItem` (they have no equivalent pipeline). Their bodies continue to be read from the raw source file in `expandContentMarker`. No change needed.
