Implementation Plan: Frontmatter History to Canonical JSONL

Branch: 035-frontmatter-history-to-canonical-jsonl | Date: 2026-02-09 | Spec: spec.md Input: Feature specification from kitty-specs/035-frontmatter-history-to-canonical-jsonl/spec.md

Summary

Replace the current bootstrap-only migrate_feature() in src/specify_cli/status/migrate.py with a full history reconstruction engine. Today, migration creates a single planned -> current_lane event per WP, discarding all intermediate transitions stored in frontmatter history[] arrays. The new engine reconstructs every adjacent transition, extracts DoneEvidence when available, and uses force=true on all migration events to bypass guard validation. A 3-layer idempotency contract (marker check, live-events skip, migration-actor-only replace) ensures safe re-runs. An upgrade migration wrapper wires this into spec-kitty upgrade for automatic execution.

Technical Context

Language/Version: Python 3.11+ (existing spec-kitty codebase) Primary Dependencies: ulid, ruamel.yaml (frontmatter parsing), typer (CLI), rich (output) Storage: Filesystem only (JSONL event logs, JSON snapshots, YAML frontmatter) Testing: pytest (2032+ existing tests) Target Platform: Cross-platform (Linux, macOS, Windows 10+) Project Type: Single Python CLI package Performance Goals: Migration of 200+ WP files across 30+ features in < 5 seconds Constraints: Must not touch features with live (non-migration) events; must be idempotent Scale/Scope: ~203 WP files across 34 features in dogfood repo; typical user projects 10-50 WPs

Constitution Check

GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.

GateStatusNotes
Python 3.11+PASSAll code targets existing codebase
pytest + 90%+ coveragePASSNew code will have dedicated test modules
mypy --strictPASSAll new modules will have full type annotations
Cross-platformPASSUses pathlib, no platform-specific code
No external service callsPASSMigration is local/offline only; SaaS sync explicitly bypassed
Git dependencyN/AMigration reads/writes files, no git operations

No violations. No complexity justification needed.

Project Structure

Documentation (this feature)

kitty-specs/035-frontmatter-history-to-canonical-jsonl/
├── spec.md              # Feature specification
├── plan.md              # This file
├── research.md          # Phase 0 research findings
├── data-model.md        # Data structures and transitions
├── quickstart.md        # Quick reference for implementers
├── meta.json            # Feature metadata
├── checklists/
│   └── requirements.md  # Quality checklist
└── tasks/               # Work packages (generated by /spec-kitty.tasks)

Source Code (repository root)

src/specify_cli/status/
├── history_parser.py     # NEW: History normalization + transition reconstruction
├── migrate.py            # MODIFY: Replace bootstrap logic with full reconstruction
├── models.py             # UNCHANGED: Lane, StatusEvent, DoneEvidence (already sufficient)
├── store.py              # UNCHANGED: append_event, read_events (use existing API)
├── reducer.py            # UNCHANGED: materialize (called after migration)
├── transitions.py        # UNCHANGED: resolve_lane_alias, ALLOWED_TRANSITIONS
└── emit.py               # UNCHANGED: NOT used by migration (bypasses guards + SaaS)

src/specify_cli/upgrade/migrations/
└── m_2_0_0_historical_status_migration.py  # NEW: BaseMigration upgrade wrapper

tests/specify_cli/status/
├── test_history_parser.py  # NEW: Parser unit tests
└── test_migrate.py         # MODIFY: Update existing 29 tests + add new coverage

tests/specify_cli/upgrade/
└── test_historical_status_migration.py  # NEW: Upgrade wrapper integration tests

Structure Decision: All new code lives within the existing status/ and upgrade/migrations/ packages. No new packages or directory restructuring required.

Architecture Decisions

AD-1: Bypass emit_status_transition() for migration

Decision: Migration writes events directly via append_event() + post-hoc materialize(), NOT through emit_status_transition().

Rationale:

  • emit_status_transition() calls validate_transition() which would reject many historical transitions (planned -> done, planned -> for_review, etc.) that aren't in ALLOWED_TRANSITIONS
  • emit_status_transition() triggers SaaS sync per event, which is unwanted during migration
  • emit_status_transition() calls materialize() after every single event, which is wasteful when writing N events per feature
  • Migration events use force=true and construct StatusEvent objects directly

AD-2: Atomic write per feature via temp file + os.replace()

Decision: For each feature, accumulate all events in memory, write them to a temp file, then os.replace() to the final path.

Rationale:

  • Current append_event() appends one line at a time (not atomic across multiple events)
  • Crash mid-write would leave a partial event log
  • Atomic swap ensures either all events or none are persisted
  • Mirrors the pattern already used by materialize() for status.json

Implementation: New helper _write_events_atomic(feature_dir, events) in migrate.py.

AD-3: 3-layer idempotency (not emit-level dedup)

Decision: Use three explicit guards instead of per-event deduplication.

LayerCheckAction
1. Marker checkAny event has reason containing historical_frontmatter_to_jsonl:v1Skip feature
2. Live events checkAny event has actor NOT starting with "migration"Skip feature
3. Migration-actor-only replaceALL events have actor starting with "migration"Backup + replace with full reconstruction

Rationale: Per-event fingerprint hashing adds complexity with no practical benefit. The migration runs once per project. These three guards cover all real scenarios.

AD-4: Migration ID 2.0.0_historical_status_migration

Decision: Use 2.0.0_historical_status_migration as the migration ID on both 2.x and 0.x branches.

Rationale: The migration framework uses has_migration(id) as a string key lookup. Same ID on both branches means metadata.yaml guards prevent double-execution regardless of which branch migrates first.

AD-5: All events use force=true

Decision: Every migration-generated event sets force=true with reason="historical migration" (or "historical migration: no evidence in frontmatter" for done without evidence).

Rationale:

  • Historical transitions bypassed live guard validation
  • Many transitions (e.g., planned -> done, planned -> in_progress) are not in the 16-pair ALLOWED_TRANSITIONS matrix
  • force=true requires actor + reason, which migration always provides
  • The validator already treats forced events as legal
  • Current migrate.py uses force=False which is a latent bug (these events would fail legality validation)

Transition Reconstruction Algorithm

Input per WP

frontmatter = {
    lane: "done",           # Current lane
    history: [              # Format A entries
        {timestamp, lane, agent, shell_pid, action},
        ...
    ],
    review_status: "approved",   # Optional
    reviewed_by: "Robert",       # Optional
}

Algorithm

1. NORMALIZE history entries:
   - For each entry: resolve_lane_alias(entry.lane) → canonical lane
   - Extract: (timestamp, canonical_lane, agent)

2. COLLAPSE consecutive duplicates:
   - [planned, planned, in_progress] → [planned, in_progress]

3. PAIR adjacent entries into transitions:
   - entries[0..N-1]: (entries[i].lane → entries[i+1].lane) for i in 0..N-2
   - Yields N-1 transitions

4. GAP-FILL to current lane:
   - If last entry's lane != current frontmatter lane:
     - Add one transition: last_entry.lane → current_lane

5. FALLBACK (empty/no history):
   - If current lane == "planned": 0 events
   - Else: 1 event: planned → current_lane

6. For each transition targeting "done":
   - If review_status == "approved" AND reviewed_by exists:
     - Attach DoneEvidence(ReviewApproval(reviewer, "approved", "frontmatter-migration:<wp_id>"))
   - Else: no evidence, force=true with reason noting missing evidence

Actor Resolution per Transition

For transition entries[i] → entries[i+1]:

  • Use entries[i+1].agent as the actor (the agent who CAUSED the transition to the new lane)
  • Fallback: "migration" if agent is empty/absent

For gap-fill and fallback transitions:

  • Actor: "migration"

Files to Create/Modify

New Files

FilePurposeLOC estimate
src/specify_cli/status/history_parser.pyHistory normalization, transition chain, evidence extraction~150
src/specify_cli/upgrade/migrations/m_2_0_0_historical_status_migration.pyBaseMigration wrapper for spec-kitty upgrade~80
tests/specify_cli/status/test_history_parser.pyParser unit tests (10+ scenarios)~250
tests/specify_cli/upgrade/test_historical_status_migration.pyUpgrade wrapper tests~120

Modified Files

FileChangesImpact
src/specify_cli/status/migrate.pyReplace bootstrap logic with history_parser calls; add 3-layer idempotency; add atomic write; add backup; call materialize()High - core engine rewrite
tests/specify_cli/status/test_migrate.pyUpdate existing tests for multi-event output + force=true; add new idempotency layer testsMedium - 29 existing tests need updates

Unchanged Files (explicitly)

FileReason
src/specify_cli/status/emit.pyMigration bypasses emit pipeline
src/specify_cli/status/store.pyExisting append_event/read_events API sufficient
src/specify_cli/status/reducer.pyExisting materialize() called as-is
src/specify_cli/status/models.pyStatusEvent, DoneEvidence, ReviewApproval already have all needed fields
src/specify_cli/status/transitions.pyresolve_lane_alias(), CANONICAL_LANES used as-is
src/specify_cli/cli/commands/agent/status.pyCLI command calls migrate_feature() which gets the new behavior automatically

Test Plan

New: test_history_parser.py

IDScenarioInputExpected
T080Multi-step Format Ahistory: [planned, doing, for_review, done], lane=done3 events: planned→in_progress, in_progress→for_review, for_review→done
T081Single entry at planned, lane=donehistory: [planned], lane=done1 event: planned→done (gap-fill)
T082Single entry at non-plannedhistory: [in_progress], lane=done2 events: planned→in_progress, in_progress→done (gap-fill)
T083Empty history, lane=doneno history, lane=done1 event: planned→done (fallback)
T084Empty history, lane=plannedno history, lane=planned0 events
T085Alias in historyhistory: [planned, doing], lane=in_progress1 event: planned→in_progress (alias resolved)
T086Consecutive duplicateshistory: [planned, planned, doing], lane=in_progress1 event: planned→in_progress
T087Gap-fill neededhistory: [planned, in_progress], lane=done2 events: planned→in_progress, in_progress→done
T088DoneEvidence extractionlane=done, review_status=approved, reviewed_by=RobertEvent has DoneEvidence with ReviewApproval
T089Done without evidencelane=done, no review fieldsEvent has force=true, reason="historical migration: no evidence in frontmatter"
T090Actor from historyhistory entries with agent="claude"Events use "claude" as actor
T091Timestamp from historyhistory entries with specific timestampsEvents preserve original timestamps

Modified: test_migrate.py

ChangeDetails
Update test_four_wps_various_lanesExpect force=True on all events; history-based multi-event output
Update all event assertionsforce=Falseforce=True, check for reason field
Add test_marker_idempotencyEvents with marker → skip
Add test_live_events_skipNon-migration actor events → skip
Add test_migration_only_replaceAll migration actors → backup + replace
Add test_backup_createdVerify .bak file exists after replace
Add test_materialization_after_migratestatus.json exists and is valid after migration
Add test_atomic_writePartial failure doesn't leave corrupt events file

New: test_historical_status_migration.py

IDScenarioExpected
T100detect() finds unmigrated featuresReturns True
T101detect() with all features migratedReturns False
T102apply() calls migrate_feature per featureCorrect event counts
T103apply() dry_run produces no filesNo events files written
T104apply() records in metadata.yamlmigration_id present
T105Cross-branch idempotencySecond apply() after metadata record → no-op

WPMigrationDetail Dataclass Update

The existing WPMigrationDetail needs to expand to report multi-event output:

@dataclass
class WPMigrationDetail:
    wp_id: str
    original_lane: str          # Raw value from frontmatter
    canonical_lane: str          # After alias resolution
    alias_resolved: bool         # True if original != canonical
    events_created: int          # Was always 0 or 1, now can be N
    event_ids: list[str]         # All event IDs for this WP
    history_entries: int         # Number of raw history entries parsed
    has_evidence: bool           # True if DoneEvidence was extracted

Backward-compatible: adds fields, doesn't remove any. The CLI JSON output helper _migration_result_to_dict needs updating to include new fields.

Risk Assessment

RiskLikelihoodImpactMitigation
Existing tests break due to force=True changeHighLowUpdate all assertions in test_migrate.py to expect force=True
Corrupt WP frontmatter causes crashLowMediumExisting try/except per WP continues to work; WP errors don't abort feature
Backup file accumulates on repeated runsLowLowBackup only created on replace-once path; marker prevents repeated replacements
Materialization fails on reconstructed eventsLowHighMaterialize uses reduce() which is a pure function; forced events are legal by design

Implementation Order

1. history_parser.py (new, no dependencies) 2. test_history_parser.py (test first, validate algorithm) 3. migrate.py (modify, depends on history_parser) 4. test_migrate.py (update existing + add new) 5. m_2_0_0_historical_status_migration.py (new, depends on migrate.py) 6. test_historical_status_migration.py (new, depends on wrapper)

This order enables incremental verification: parser tests pass before engine is wired in, engine tests pass before upgrade wrapper is added.