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.shCallers: /update-story (scripts/story/update-story.sh, config scripts/story/story.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.

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.