Skip to content

Narrative-from-Git Engine

The shared deterministic core behind narrative pages that stay current with the repo (#847). It recomputes live counts, restamps the generated date (via timelord), and detects new feature commits and newly-added surface files since the last marker — everything mechanical about a refresh. The narrative judgement (whether recent work is a new chapter, and writing it in the page’s voice) always stays with the caller.

Engine (single source of truth): scripts/narrative/engine.sh Callers: /update-story (scripts/story/update-story.sh, config the project’s story/story.config.json, else the generic scripts/story/story.default.config.json) and /update-journey (scripts/journey/update-journey.sh, config scripts/journey/journey.config.json), which refreshes the workday scenarios page — same core, different config.

/update-story is a registry command (applies_to: any, recommended_for: [content]), so any project can provision it with bob provision add command update-story. Two things make it work outside BoB.

Per-project config. update-story.sh passes the engine the project’s own story/story.config.json when there is one. Otherwise it passes the generic default beside the script, scripts/story/story.default.config.json. The default counts only git-derived facts, commits and releases (v* tags), and reports new docs/**/*.md files as its one surface group. A project that wants more counts declares them in its own config, using the schema below. BoB does this in story/story.config.json to count skills, commands, agents, hooks, runbooks and projects.

First generation (--init). The engine refreshes a page; it never creates one. When story/index.html is missing, update-story.sh --init creates it with scripts/story/init-story.js, from the generic template templates/story/index.html:

Page part Source
Title and lede the first H1 and first prose paragraph of README.md, else CLAUDE.md, else the directory name
Stats strip one data-story hook per key in the resolved config’s counts
Timeline git log: one era per month, or per quarter once the history spans 12 months. Each era carries its commit and feature counts, its feature subjects, and its conventional-commit scopes as chips. The first era is marked major.
Further reading docs/**/*.md, titled by frontmatter title: or the first H1 (up to 24 links)
Marker <!-- story:timeline-end -->, where later eras are inserted

All text is HTML-escaped and the template has no project-specific content. After rendering, --init runs the normal refresh, which stamps every count and the date and writes story/story-state.json. The backfilled eras are a factual draft, so the author rewrites them in the page’s voice. --init never regenerates an existing page: on one, it is a plain refresh. With --check it reports what it would create and writes nothing. Without --init, a missing page exits 1 with a message naming --init.

tests/test-update-story.sh covers the config fallback, the project override, BoB’s own counts, --init on a fresh fixture repo, and the no-op second run.

Terminal window
engine.sh --config <config.json> [PROJECT_ROOT] [--check] [--json]
Argument Meaning
--config <path> Required. The caller config (schema below). Relative paths inside it resolve against PROJECT_ROOT.
PROJECT_ROOT Defaults to the current git toplevel.
--check Dry run — report whether an update is needed, write nothing.
--json Emit the machine-readable summary on stdout.

Exit codes: 0 = success (whether or not an update was applied), 1 = error.

A caller is a thin wrapper: a config file plus a two-line shell script that execs the engine with that config. Nothing page-specific lives in the engine — if you need to hard-code a path or key in engine.sh, the config schema is missing a field and should grow one instead.

#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/../narrative/engine.sh" --config "$SCRIPT_DIR/<caller>.config.json" "$@"

The page being refreshed must mark every value the engine restamps with a stable data-attribute hook — <span data-story="commits">1230</span> — and the engine replaces only the hook’s inner text. Positional/regex edits against the page’s prose are never used, which is what makes the refresh idempotent and markup-safe.

{
"page": "story/index.html", // HTML page updated in place
"state": "story/story-state.json", // marker + last-written counts
"data_attr": "data-story", // hook attribute used in the page
"date_key": "gendate", // hook key that receives the generated date
"feat_pattern": "\\|feat", // ERE matched against "%h|%ad|%s" log lines
"counts": { // key order = order in the state file
"commits": { "type": "git-commits" },
"releases": { "type": "git-tags", "pattern": "v*" },
"skills": { "type": "dirs", "paths": ["skills", "registry/skills"] },
"commands": { "type": "glob", "patterns": ["commands/*.md"] }
},
"surfaces": { // optional: new-surface detection groups
"commands": ["commands/*.md", "registry/commands/*.md"]
}
}
type Counts Extra fields
git-commits git rev-list --count HEAD —
git-tags tags matching pattern (default v*) pattern
dirs subdirectories across each of paths paths: []
glob files matching each of patterns (shell glob, repo-relative) patterns: []

Every key in counts must have a matching data_attr hook in the page; the engine also writes each key into the state file’s counts block, which is how the no-op fast path decides nothing changed.

Each surfaces group is a list of globs; the engine reports files added (git diff --diff-filter=A) in the marker..HEAD range that match. This is the “genuinely new commands/skills/agents” signal a caller narrates from. With no marker yet (genesis), groups are empty — everything being “new” is noise, not signal.

{
"changed": true, // counts changed, new feats exist, or no marker yet
"check": false, // was this a --check run
"head": "f6ac767", "gendate": "2026-07-17",
"prev_commit": "b215b91", "prev_date": "2026-06-20",
"new_feat_count": 348,
"counts": { "commits": 1230, "...": 0 },
"new_feats": [ { "sha": "5ac87ff", "date": "2026-06-20", "subject": "feat(...): ..." } ],
"new_surfaces": { "commands": ["registry/commands/foo.md"] } // only when "surfaces" configured
}

new_feats (feature commits since the marker, oldest first) and new_surfaces are the caller’s raw material for narration. changed: false means the page is already current — the engine wrote nothing and the caller should stop.

The state file records what the page was last generated through:

{
"generated_through": { "commit": "f6ac767", "date": "2026-07-17" },
"counts": { "commits": 1230 }
}

The engine owns this file entirely — callers never edit it. Re-running with no new history is a clean no-op (idempotent), so callers are safe to run on every session end, in CI, or ad hoc.