Skip to content

Build master @ bc50cd5bunreleased — 31 commits past v1.282.0Last reviewed Last changed Grade fresh (aging >90d, stale >180d)Source / History

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.shCallers: /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 in any project (#370) ​

/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 partSource
Title and ledethe first H1 and first prose paragraph of README.md, else CLAUDE.md, else the directory name
Stats stripone data-story hook per key in the resolved config's counts
Timelinegit 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 readingdocs/**/*.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.

Invocation ​

bash
engine.sh --config <config.json> [PROJECT_ROOT] [--check] [--json]
ArgumentMeaning
--config <path>Required. The caller config (schema below). Relative paths inside it resolve against PROJECT_ROOT.
PROJECT_ROOTDefaults to the current git toplevel.
--checkDry run — report whether an update is needed, write nothing.
--jsonEmit the machine-readable summary on stdout.

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

Writing a new caller ​

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.

bash
#!/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.

Config schema ​

jsonc
{
  "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"]
  }
}

Count types ​

typeCountsExtra fields
git-commitsgit rev-list --count HEAD—
git-tagstags matching pattern (default v*)pattern
dirssubdirectories across each of pathspaths: []
globfiles 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.

Surfaces ​

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.

Output contract (--json) ​

jsonc
{
  "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.

State marker ​

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

json
{
  "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.