Git-Native Entity History
Derive structured lifecycle timelines for plan entities from git history — attribute transitions, criteria progress, and resolution events — surfaced through the CLI and as a site rune.
Problem
Plan entities live as Markdown files in git. Every status transition, priority change, and checked criterion is a commit. But the system doesn't expose this history. The plan-activity rune shows when files were last modified. The plan status CLI shows current state. Neither answers the questions people actually ask:
- When did this item move from ready to in-progress?
- Who changed the priority, and in what commit?
- Which acceptance criteria were checked off last, and when?
- What happened across the project this week?
Other issue trackers (Jira, Linear, GitHub Issues) maintain activity logs, but theirs are opaque database records — you can't verify them, you can't see what else changed in the same operation, and you can't correlate changes across entities that moved together. Git history is richer: every change has a commit hash, an author, a message explaining why, and a full diff showing what else happened alongside it.
The data is already there. The system just doesn't read it.
Design Principles
Git is the source of truth. No separate activity log, no event table, no additional state to maintain or sync. History is derived from git commits by diffing consecutive versions of each entity file. If git says it happened, it happened. If git doesn't, it didn't.
Structured events, not raw diffs. A git log -p dump is not useful to anyone. The system parses diffs into typed events: "status changed from draft to ready", "criterion 'Unit tests passing' checked", "resolution recorded with branch claude/feature-x". The output is a timeline of meaningful project events.
Commit messages are first-class context. In most trackers, the "why" behind a change is lost or buried in a comment. Here, the commit message is directly associated with every event. "Accept SPEC-037 and break into work items" tells you why the status changed — no separate annotation needed.
Cross-entity correlation is natural. When a single commit touches 5 work items and a spec, that's visible. The global history feed groups events by commit, showing atomic operations as atomic operations. This is information that database-backed trackers structurally cannot surface.
Event Model
Event types
Every history event has a commit hash, date, author, and commit message. The kind field distinguishes what changed:
| Kind | Meaning | Example |
|---|---|---|
created | Entity file first appeared | File added in commit |
attributes | One or more tag attributes changed | status: draft → ready, priority: medium → high |
criteria | Acceptance criteria checkboxes changed | ☑ "Unit tests passing", ☐ "API endpoint created" (unchecked) |
resolution | A ## Resolution section was added or modified | Resolution recorded with branch and PR metadata |
content | File changed but no attribute/criteria/resolution diff detected | Body text edited, sections added |
Attribute changes
Extracted by parsing the opening Markdoc tag (always line 1) at consecutive commits and diffing the attribute maps:
interface AttributeChange {
field: string; // "status", "priority", "source", "assignee", etc.
from: string | null; // null = attribute was added
to: string | null; // null = attribute was removed
}
All plan entity attributes are on a single line, making extraction reliable — a simple regex parse of line 1 at each commit version.
Criteria changes
Extracted by collecting - [ ] and - [x] lines from consecutive commits and diffing them:
interface CriteriaChange {
text: string; // criterion text (trimmed)
action: 'checked' | 'unchecked' | 'added' | 'removed';
}
Text matching is used to correlate criteria across commits. If criterion text is reworded, it appears as a remove + add pair — acceptable since rewording criteria is a meaningful change.
Resolution events
Detected by the appearance or modification of a ## Resolution section. The existing scanner already parses Completed:, Branch:, and PR: metadata lines from resolution sections — the same parser is reused.
Content events
A fallback for commits that changed the file but produced no attribute, criteria, or resolution diffs. These are body edits — added sections, rewritten descriptions, ref tag conversions. The event is recorded with the commit metadata but no structured diff detail. The commit message provides the context.
Data Extraction
Per-entity extraction
For a single entity file, the extraction algorithm:
- Run
git log --follow --format="%H %aI %aN" -- <filepath>to get the ordered commit list - For each commit hash, run
git show <hash>:<filepath>to get the file contents at that point - From each version, extract:
- Opening tag attributes (line 1 regex parse)
- Checkbox lines (
- [ ] textand- [x] text) - Whether a
## Resolutionsection exists (and its metadata if so)
- Walk commits from oldest to newest, diffing consecutive snapshots
- Emit typed
HistoryEventobjects
This is the approach used by the CLI plan history command. For a file with N commits, it requires 1 git log call + N git show calls. At typical scales (2–10 commits per file), this completes in well under a second.
Batch extraction
For the site build and global CLI feeds, extracting history for all entities:
- Run
git log --format="%H %aI %aN %s" --name-only -- <plan-dir>to get all commits with affected file lists in a single call - Group commits by file path
- For files with more than one commit, run the per-entity extraction
- For files with exactly one commit, emit a single
createdevent - Merge all events into a unified timeline, sorted by date
Caching
The .plan-cache.json file already stores per-entity data keyed by file mtime. History extraction results can be cached alongside this data, keyed by the latest commit hash for each file. Cache invalidation is exact: if the latest commit hash hasn't changed, the history hasn't changed.
Cache structure per entity:
interface HistoryCacheEntry {
latestCommit: string; // hash of most recent commit touching this file
events: HistoryEvent[]; // extracted events, oldest first
}
The cache is populated lazily (CLI: on first plan history call) or eagerly (site build: during the aggregate phase). Subsequent runs skip files whose latest commit hash matches the cache.
Shallow clone handling
The existing getGitTimestamps() utility in @refrakt-md/content already detects shallow clones via git rev-parse --is-shallow-repository. History extraction should follow the same pattern: in shallow clones, emit only events for available commits and mark the timeline as potentially incomplete. The CLI should warn; the site rune should display a note.
CLI: plan history
Single-entity mode
npx refrakt plan history WORK-024
Output:
WORK-024: Add knownSections to content model framework
Apr 12 status: ready → done a295513
☑ knownSections supported in the content model framework
☑ Work rune declares known sections with aliases
☑ Bug rune declares known sections with aliases
☑ Decision rune declares known sections with aliases
☑ Alias matching is case-insensitive
☑ Unknown sections still pass through via sectionModel fallback
☑ Validation warns on missing required sections
☑ Tests for alias resolution and fallback behaviour
Apr 12 status: blocked → ready 1676387
priority: low → medium
source: +SPEC-037
Apr 10 source: +SPEC-003, +SPEC-021 f262d7b
Apr 08 Created (blocked, low, moderate) da12420
Events are displayed newest-first (reverse chronological). Each event shows the date, the structured changes, and the short commit hash. Sub-changes within an event (multiple attributes changed, multiple criteria checked) are indented under the date line.
Global mode
npx refrakt plan history --limit 20
Shows recent events across all entities, grouped by commit when multiple entities change in the same commit:
Apr 12 a295513 Mark all SPEC-037 work items done
WORK-024 status: ready → done (☑ 8/8 criteria)
WORK-127 status: ready → done (☑ 3/3 criteria)
WORK-128 status: ready → done (☑ 4/4 criteria)
WORK-129 status: ready → done (☑ 3/3 criteria)
WORK-130 status: ready → done (☑ 2/2 criteria)
WORK-131 status: ready → done (☑ 5/5 criteria)
Apr 12 1676387 Accept SPEC-037 and break into work items
SPEC-037 status: draft → accepted
WORK-024 status: blocked → ready, priority: low → medium
WORK-127 Created (ready, high, simple)
WORK-128 Created (ready, medium, simple)
WORK-129 Created (ready, medium, moderate)
WORK-130 Created (ready, low, trivial)
WORK-131 Created (ready, medium, simple)
The commit-grouped format shows atomic operations as single entries. This is a direct advantage of the git-native approach — you can see that a spec was accepted and 5 work items were created in one operation, because that's how it actually happened.
Filters
# Filter by time
npx refrakt plan history --since 7d
npx refrakt plan history --since 2026-04-01
# Filter by entity type
npx refrakt plan history --type work
npx refrakt plan history --type spec,decision
# Filter by author
npx refrakt plan history --author claude
# Filter by specific status transitions
npx refrakt plan history --status done # show items that became "done"
# Combine filters
npx refrakt plan history --since 7d --type work --status done --limit 50
# JSON output
npx refrakt plan history WORK-024 --format json
npx refrakt plan history --since 7d --format json
The --since filter maps directly to git log --since, so it's efficient — git does the filtering, not the application. The --type, --author, and --status filters are applied post-extraction.
Site Rune: plan-history
Per-entity mode
{% plan-history id="WORK-024" /%}
Renders a vertical timeline for a single entity. Each event is a list item showing the date, change summary, and commit hash (linked if a repository URL is configured). Criteria changes are rendered as a compact checklist diff. Attribute changes show field: old → new with appropriate styling.
Global feed mode
{% plan-history limit=20 /%}
{% plan-history limit=10 type="work" /%}
{% plan-history since="7d" /%}
Renders a commit-grouped activity feed. Each commit is a section showing the date, commit message, and a list of entity changes within that commit. Entities that changed together are visually grouped.
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
id | String | — | Entity ID for single-entity mode. Omit for global feed. |
limit | Number | 20 | Maximum number of events (per-entity) or commits (global) to show |
type | String | "all" | Entity type filter: work, bug, spec, decision, or comma-separated |
since | String | — | Time filter: "7d", "30d", or ISO date. Maps to git --since. |
group | String | "commit" | Global mode grouping: commit (group by commit) or entity (group by entity) |
Auto-injection
Like the auto-relationships-section in the existing postProcess hook, entity pages can optionally receive an auto-injected History section. This is controlled by a package-level configuration flag rather than a per-entity attribute — history is either on for all entities or off.
When enabled, the postProcess hook appends a "History" section after the existing auto-relationships section, using the per-entity timeline format. The section is omitted for entities with only a single commit (created and never modified) to avoid noise.
Implementation pattern
The rune follows the established self-closing aggregation rune pattern:
- Tag definition (
tags/plan-history.ts):selfClosing: true, stores parameters as meta tags, emits a sentinel marker and empty placeholder - Aggregate hook extension: History data is extracted during the aggregate phase and included in
PlanAggregatedData - PostProcess resolution: Detects the sentinel, reads parameters, builds the timeline HTML from cached history data, replaces the placeholder
Rendering
Visual language
The plan-history rune draws from two existing rune visual conventions:
Timeline structure — from the timeline rune (@refrakt-md/business). The outer layout follows the same vertical connected-line pattern: an <ol> of entries with a left border line (2px solid), circular markers (0.75rem diameter) at each event, <time> elements for dates, and content indented to the right. This is the established visual language for temporal sequences in the design system. Plan-history adopts the same proportions and token references (--rf-color-border, --rf-color-primary for markers) but under its own .rf-plan-history BEM namespace, so the plan package remains self-contained without depending on @refrakt-md/business.
Diff colouring — from the diff rune (core). Attribute changes use the same data-type="add|remove" convention and background tints (rgba(63, 185, 80, 0.15) for additions, rgba(248, 81, 73, 0.15) for removals) that the diff rune uses for added/removed lines. This gives "status: blocked → ready" the same visual weight and meaning as a code diff — red for what was, green for what is. The design tokens are shared, not duplicated.
If a third consumer of the vertical connected-timeline pattern emerges, the shared structure (border line, markers, spacing, <time> element conventions) should be extracted into a lumina layout primitive. For now, two consumers with aligned conventions is sufficient.
Per-entity timeline
● Apr 12 ······································ a295513
│ status: ready → done
│ ☑ knownSections supported in content model framework
│ ☑ Work rune declares known sections with aliases
│ ☑ Bug rune declares known sections with aliases
│ … (+5 more criteria)
│
● Apr 12 ······································ 1676387
│ status: blocked → ready
│ priority: low → medium
│ source: +SPEC-037
│
● Apr 10 ······································ f262d7b
│ source: +SPEC-003, +SPEC-021
│
○ Apr 08 ······································ da12420
Created (blocked, low, moderate)
HTML structure:
<section class="rf-plan-history" data-rune="plan-history">
<ol class="rf-plan-history__events">
<li class="rf-plan-history__event">
<time class="rf-plan-history__date">Apr 12</time>
<code class="rf-plan-history__hash">a295513</code>
<div class="rf-plan-history__changes">
<span class="rf-plan-history__change">
<span class="rf-plan-history__field">status</span>
<span class="rf-plan-history__value" data-type="remove">ready</span>
<span class="rf-plan-history__arrow">→</span>
<span class="rf-plan-history__value" data-type="add">done</span>
</span>
<ul class="rf-plan-history__criteria">
<li data-action="checked">☑ knownSections supported in …</li>
<!-- ... -->
</ul>
</div>
</li>
<!-- ... more events -->
</ol>
</section>
Key details:
<ol>with left border and circle markers, matching the timeline rune's vertical connected layout- Filled circle (
●) for events with structured changes; open circle (○) for creation events — mirrors the timeline rune's marker style with a semantic distinction - Attribute values use
data-type="add|remove"for diff-style background tinting, matching the diff rune's convention <time>elements for dates, matching the timeline rune's semantic HTML<code>for commit hashes, monospace and subdued — links to the hosting platform when a repository URL is configured- Status transition changes use the existing sentiment colour system —
donegets positive styling,blockedgets negative, transitions show the "to" sentiment
When more than 3 criteria change in a single event, the list is collapsed with a "+N more criteria" summary to keep timelines compact. The collapsed items are still present in the DOM for accessibility.
Global feed (commit-grouped)
● Apr 12 ── a295513
│ Mark all SPEC-037 work items done
│
│ WORK-024 status: ready → done (☑ 8/8)
│ WORK-127 status: ready → done (☑ 3/3)
│ WORK-128 status: ready → done (☑ 4/4)
│ … (+3 more entities)
│
● Apr 12 ── 1676387
│ Accept SPEC-037 and break into work items
│
│ SPEC-037 status: draft → accepted
│ WORK-024 status: blocked → ready
│ WORK-127 Created (ready, high, simple)
│ … (+3 more entities)
The global feed uses the same timeline structure but each entry represents a commit rather than a single-entity event. The commit message is displayed as the entry's primary content, with affected entities listed below as compact summary lines. Entity IDs link to the entity's page when source URLs are available.
BEM additions for global mode: .rf-plan-history--global, .rf-plan-history__commit-message, .rf-plan-history__entity-summary.
Decisions
1. Criteria collapse threshold
Show first 3 criteria, collapse the rest with "+N more criteria". This keeps timelines scannable when an event checks off 8+ criteria at once, while still showing enough detail to understand what happened. The collapsed items remain in the DOM for accessibility and can be expanded. In the global feed, criteria are always summarised as a count (☑ 8/8) rather than listed individually.
2. Content-only event visibility
Show content events (body edits with no attribute/criteria/resolution change) in per-entity mode for completeness — every commit that touched the file appears in its timeline. Omit them from the global feed where they'd be noise. The CLI --all flag overrides this, showing content events in global mode when explicitly requested.
3. Repository URL configuration
Parse from git remote get-url origin at build time, with a repository field in refrakt.config.json as override. The git remote approach works automatically for most setups; the config override handles cases where the remote URL is SSH, uses a non-standard host, or needs a different base URL (e.g., self-hosted GitLab). The URL is parsed once during the aggregate phase and reused for all commit links.
4. Rename/move tracking
Always use git log --follow to track file history across renames. Pre-rename commits are included in the entity's timeline. The rename itself is not surfaced as an event — it's a filesystem concern, not a project management one. This ensures entity history is complete even when files are reorganised.