Repository navigation
v0.2.9 P1 — Data model + Display policy + Migration - #26
Merged
Merged
Conversation
added 10 commits
April 27, 2026 14:21
Focused P1 implementation plan derived from PLAN-v0.2.9 § P1. Locked decisions: - Dual-write strategy: V1 write retained in P1; V2 is new authoritative source - UnderstandingStore.swift stays; LegacyUnderstandingAdapter added alongside - Display precedence: Manual(v2) > AI(v2) > Legacy > Rule > UUID for title/progress - DisplayPolicy is pure (no I/O) - LLMEnhancer unchanged in P1 - No edit UI; manual chip path covered by unit tests only - xcodeproj membership updated in same commit as Swift file changes - 6 internal commits, full 4-way verification per commit PLAN-only. No implementation in this commit.
Add v0.2.9 understanding-layer model types. No store, no integration, no UI changes — types only. New types in Models/UnderstandingArtifact.swift: - UnderstandingSource (rule | ai | manual) - UnderstandingTrigger (scan | manualGenerate | manualEdit) - StaleState (fresh | staleSessionUpdated | stalePartial | legacyUnknown) - UnderstandingArtifact (versioned title/progress/summary value) - UnderstandingField (title | progress | summary) - SelectionAction (adopt) - SelectionEvent (records previousVersionID + targetVersionID + timestamp) - RationaleMetadata (current-only optional explanation) - LegacyUnderstandingSnapshot (all-optional pre-v0.2.9 baseline) New type in Models/UnderstandingState.swift: - UnderstandingState (per-session container with version chains, current pointers, selection events, rationale slot, legacy slot) All types are public, Sendable, Equatable. UnderstandingArtifact and SelectionEvent are Identifiable. xcodeproj membership added in this same commit per dual-build rule. Tests: UnderstandingArtifactTests (compile-oriented contracts, defaults, StaleState equality with associated values, optionality, state mutability, enum allCases). Verification: - swift build clean - swift test: 359 tests pass (was 346, +13 new) - xcodebuild build clean - xcodebuild test: 258 tests pass (was 245, +13 new)
Add v0.2.9 versioned understanding store. No SessionStore or UI
integration in this commit.
UnderstandingStoreV2 (Sources/ClaudeSessionHub/Services/):
- Persists per-session UnderstandingState to ~/.claude-hub/understanding-v2.json
- Schema version "1" envelope format
- Legacy snapshot is NOT persisted (derived from V1 at read time)
- ISO8601 date encoding for stable, human-readable JSON
- Atomic writes via Data.write(options: .atomic)
- NSLock for thread safety
- Lazy-load on first access; failed load treated as empty store
- StaleState encoded as tag-based JSON to handle associated values
API:
- state(for: sessionID) -> UnderstandingState?
- appendArtifact(for:field:_:) — applies pointer rules:
* manual artifact always moves pointer
* AI/rule artifact moves pointer iff current pointer is nil or
points to a non-manual artifact (manual override is preserved)
* summary has no manual path; AI always moves pointer
- setCurrentPointer(for:field:to:) throws StoreError.versionNotFound
if target ID is not in the chain; state unchanged on throw
- appendSelectionEvent(for:_:) records adoption history with
previousVersionID + targetVersionID
Tests: UnderstandingStoreV2Tests covers
- empty/missing store
- schema version in file
- artifact + selection event round-trip
- StaleState round-trip across all 4 cases (incl. associated values)
- pointer rules: AI-over-nil, AI-over-AI, AI-over-manual,
manual-over-AI, manual-over-manual
- summary AI always moves pointer
- setCurrentPointer valid + invalid version + unknown session
- legacySnapshot must NOT appear in V2 file
- multi-session isolation
xcodeproj membership added in this same commit per dual-build rule.
Verification:
- swift build clean
- swift test: 377 (was 359, +18 new)
- xcodebuild build clean
- xcodebuild test: 276 (was 258, +18 new)
Add read-only adapter over the V1 understanding.json file. No writes, no rename, no UnderstandingStore changes, no SessionStore wiring. LegacyUnderstandingAdapter (Sources/ClaudeSessionHub/Services/): - Reads ~/.claude-hub/understanding.json with default JSONDecoder to match the V1 producer (UnderstandingStore uses default JSONEncoder) - Lenient decoding via private LooseV1Snapshot — every field is optional so partial records are tolerated - Drops V1 basedOnLastActiveAt: legacy data carries no trustworthy staleness signal under v0.2.9 (StaleState.legacyUnknown covers it) - Lazy-load with NSLock; failed load treated as empty - invalidateCache() exposed for tests - Never opens the file for writing or modifies attributes Tests: LegacyUnderstandingAdapterTests covers - missing file / missing directory - valid V1 round-trip via real UnderstandingStore.setSnapshot - partial fields: title only / title+progress / title+progress+summary - absent generatedAt -> nil - never-writes: mtime preserved + byte-identical contents across multiple read calls including post-invalidateCache re-read xcodeproj membership added in this same commit per dual-build rule. Verification: - swift build clean - swift test: 386 (was 377, +9 new) - xcodebuild build clean - xcodebuild test: 285 (was 276, +9 new)
Add pure functional resolver for v0.2.9 display precedence. No I/O,
no persistence, no UI. Caller passes state, legacy snapshot, rule
fallbacks, and (for title) a session ID for the UUID prefix tail.
Public types:
- ResolvedSource: ai | manual | rule | legacy | uuidPrefix | none
- ResolvedField: value? + source + staleState? + artifactID? +
invalidPointer marker
Public API:
- resolveTitle(state:legacy:ruleTitle:sessionIDForFallback:)
Precedence: Manual(v2) > AI(v2) > Legacy.title > Rule > UUID prefix
- resolveProgress(state:legacy:ruleProgress:)
Precedence: Manual(v2) > AI(v2) > Legacy.progress > Rule/derived
- resolveSummary(state:legacy:)
Precedence: AI(v2) > Legacy.summary > nil (no manual path)
Resolution rules:
- Pointer override: if currentXVersionID points to a real artifact in
the chain, use that exact artifact regardless of source order.
- Invalid pointer: pointer set but not in chain → walks precedence
fallback and surfaces invalidPointer=true.
- Latest manual / latest AI within chain via reverse-iteration find.
- Legacy carries staleState=.legacyUnknown.
- Rule and UUID carry no staleState.
- Empty legacy strings ("" title) are treated as missing.
Tests: UnderstandingDisplayPolicyTests covers
- All precedence combinations from P1 plan (title/progress/summary)
- Pointer overrides for AI when manual exists
- Pointer on manual with newer AI in chain
- Invalid pointer fallback to chain / legacy / UUID
- StaleState propagation across all sources
- Latest-of-source semantics for repeated AI/manual entries
- Legacy partial fields (title only, title+progress, title+progress+summary)
- Summary no-manual fallback path even with .manual artifact present
- ResolvedSource.allCases ordering
xcodeproj membership added in this same commit per dual-build rule.
Verification:
- swift build clean
- swift test: 419 (was 386, +33 new)
- xcodebuild build clean
- xcodebuild test: 318 (was 285, +33 new)
Wire UnderstandingStoreV2, LegacyUnderstandingAdapter, and
UnderstandingDisplayPolicy into SessionStore. Update ClaudeSessionHubApp
init to construct and inject the new services. Add UnderstandingMigrationTests.
SessionStore changes:
- Add public properties: understandingV2, legacyAdapter, displayPolicy
- Keep existing understandingStore (V1) in place for dual-write
- All new init parameters default to fresh instances so existing
callers continue to compile unchanged
- Refactor enhanceWithLLM to call new persistEnhancement helper
- persistEnhancement implements dual-write semantics:
* V1: existing UnderstandingStore.setSnapshot (downgrade safe)
* V2: append AI title artifact + (optional) progress + (optional)
summary to UnderstandingStoreV2 with source=.ai, trigger=.manualGenerate
* Pointer movement follows C2 rules: AI dual-write does NOT displace
a current manual pointer
* Optional fields (progress, summary) are skipped when nil/empty
ClaudeSessionHubApp changes:
- Construct understandingV2, legacyAdapter, displayPolicy alongside
existing understandingStore
- Pass all four into SessionStore init
Views: unchanged in this commit (will switch to display policy in C6).
UnderstandingMigrationTests covers:
- V1 exists, V2 absent: legacy adapter exposes V1 data; V2 file is not
pre-created
- V1 mtime unchanged on app init / read-only access
- V1 mtime advances after dual-write enhance
- Dual-write creates V2 file on first enhance
- Same session after dual-write: both V1 and V2 reflect new content
(downgrade safety preserved)
- Same session V2 wins over legacy via display policy (set up via
direct V2 write to isolate from dual-write)
- Different sessions: legacy-only and V2-only sessions resolve correctly
- V1 absent + direct V2 write: legacy nil, V2 wins
- Neither file: rule fallback / UUID fallback work
- Partial V1 (title only): handled by display policy with .none for
missing progress/summary
- Dual-write omits optional artifacts when snapshot fields are nil
- Dual-write pointer rules: manual override is preserved
xcodeproj membership added in this same commit per dual-build rule.
Verification:
- swift build clean
- swift test: 431 (was 419, +12 new)
- xcodebuild build clean
- xcodebuild test: 330 (was 318, +12 new)
Migrate session-tile and LLM panel views to read through the v0.2.9 display policy. Render minimal source chips (AI / 规则 / Legacy / 手动) and a "Pre-v0.2.9 baseline" hint when the current value comes from legacy. AI enhance buttons + panel layout substantially unchanged. Service-layer changes: - UnderstandingStoreV2: marked @observable so SwiftUI views observing state(for:) re-render after appendArtifact / pointer / event mutations. - SessionStore: new resolvedTitle / resolvedProgress / resolvedSummary accessors. Each composes V2 state + legacy snapshot + rule fallback (smart title / current task summary / cleaned raw title) and returns ResolvedField via UnderstandingDisplayPolicy. Internal helper ruleTitleFallback(for:session:) chooses smart title or cleaned raw. View changes (no UI redesign): - New shared SourceChip view in Views/Common/. Maps: .ai → AI (purple), .manual → 手动 (blue), .rule → 规则 (secondary), .legacy → Legacy (orange) with "Pre-v0.2.9 baseline" tooltip and combined accessibility label. .uuidPrefix and .none render nothing. - SessionTileView: displayTitle / titleSource / bestSummary now read through SessionStore.resolved* accessors. Title chip uses SourceChip color/label mapping; legacy variant carries the baseline tooltip. - LLMPanelView: rewritten to read resolved title/progress/summary; per-field SourceChip rendered next to each value. When any current field source is .legacy, a "Pre-v0.2.9 baseline" hint is displayed with accessibilityIdentifier `legacyBaselineHint_<sessionID>`. hasUnderstandingContent (any AI/manual/legacy current value) decides between showing values vs the generate-AI call to action. Tests: - SessionStoreResolvedAccessorsTests covers the seam: * UUID prefix fallback when nothing exists * Legacy visibility for V1-only sessions * AI wins after dual-write enhance * Progress legacy-only / .none-when-empty * Summary AI over legacy after dual-write * Summary .none when legacy has no summary * Cross-field: regenerate replaces legacy chip on title/progress/ summary atomically (AI on all three) xcodeproj membership added in this same commit per dual-build rule. Verification: - swift build clean - swift test: 439 (was 431, +8 new) - xcodebuild build clean - xcodebuild test: 338 (was 330, +8 new)
C6 silently dropped the v1 panel's `model · time · 已过期` row, which violates the P1 acceptance "AI enhance UI works unchanged from user perspective" and weakens the v0.2.9 trust story. Add a `metadata` accessor that picks the best signal: 1. V1 snapshot (from dual-write) — full fidelity including `basedOnLastActiveAt`-driven `已过期` warning 2. V2 latest AI artifact (summary > title > progress) — used when V1 isn't available; uses `createdAt` as a stale proxy 3. Legacy snapshot — surfaces model + generatedAt with no stale claim (legacy carries .legacyUnknown, never raises 已过期) 4. nil → metadata row hidden, only the Generate / Regenerate button shows Stale marker now has accessibilityIdentifier `staleMarker_<sessionID>` for future UI-test reach. No data-model or store changes. P1 dual-write keeps V1 in sync, so this restoration is transparent for AI-enhanced sessions. Verification: - swift build clean - swift test: 439 (no change in count) - xcodebuild build clean - xcodebuild test: 338 (no change in count)
The earlier draft inherited a "mirror tests under Tests/TestRunner/ Services/" line from the parent PLAN. The actual codebase keeps TestRunner as a small selective subset (only ClaudeProviderTests) and all new C1-C6 tests already live in Tests/XCTests/ without mirrors. Update the PLAN to reflect reality so future commits don't get blocked on a non-existent rule. Pure docs change — no source / test / xcodeproj impact.
The C6 followup put the V1 snapshot branch first in the metadata
accessor. Legacy-only sessions live in the same V1 file (the legacy
adapter reads it), so that branch fired for them and surfaced 已过期
even though their resolved sources are all .legacy with
StaleState.legacyUnknown semantics.
Make the accessor source-aware and lift it from the view to SessionStore
so the regression is unit-testable:
- New ResolvedMetadata struct (model, time, isStale) in the display
policy file.
- SessionStore.resolvedMetadata(for:) inspects the resolved sources of
title/progress/summary:
* If any source is .ai or .manual → V2 has taken ownership; use V1
snapshot for full fidelity (model + generatedAt + the existing
basedOnLastActiveAt-driven stale signal). Falls back to the latest
V2 AI artifact's createdAt as a stale proxy if V1 is absent (only
relevant after a future PR removes dual-write).
* Else if any source is .legacy → legacy-only baseline; use
legacyAdapter and force isStale = false.
* Else → nil (panel hides metadata row).
- LLMPanelView.metadata is now a one-liner that delegates to the store.
Tests (SessionStoreResolvedAccessorsTests):
- testLegacyOnlyMetadataNeverStale: pre-populates V1 with old
basedOnLastActiveAt, asserts V1 isStale is true on its own (the bug
surface), then asserts resolvedMetadata.isStale is false because all
resolved sources are .legacy.
- testV2ActiveMetadataPropagatesV1Stale: verifies that V2-active
sessions still surface 已过期 via the V1 snapshot path.
- testNoContentMetadataIsNil: empty session → nil metadata so the panel
hides the row.
Verification:
- swift build clean
- swift test: 442 (was 439, +3 new)
- xcodebuild build clean
- xcodebuild test: 341 (was 338, +3 new)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First implementation PR for v0.2.9. Establishes the versioned understanding-layer data model, hybrid migration, and display precedence. AI enhance keeps using existing UI buttons; views now read through the new display policy.
Scope (from PLAN-v0.2.9 § P1 + PLAN-v0.2.9-P1)
UnderstandingArtifact,SelectionEvent(withpreviousVersionID+targetVersionID),RationaleMetadata,LegacyUnderstandingSnapshot,UnderstandingStateUnderstandingStoreV2atunderstanding-v2.jsonwith schema version "1", per-field version chains, current pointers, selection-event logunderstanding.json(V1) — never writes, lenient decoding for partial recordsUnderstandingDisplayPolicypure resolver with precedence:resolvedMetadata(for:)accessor used by the AI panelOut of scope (deferred to P2-P7)
Pointer movement rules (from C2)
setCurrentPointervalidates target exists; throwsversionNotFoundand leaves state untouched on invalid IDMigration (Hybrid)
understanding.jsonis never renamed or mutated by v0.2.9 readsunderstanding-v2.jsonis created on first AI enhanceTwo review iterations folded in
Restore AI panel metadata + stale row (
2f78c80)C6 dropped the v1 panel's
model · time · 已过期row. Restored using the V1 snapshot when available, V2 artifact metadata as fallback, legacy snapshot as last resort. Stale marker hasaccessibilityIdentifierstaleMarker_<sessionID>.Legacy-only metadata never claims stale (
d285b48)The first fix put the V1 branch first; legacy data lives in V1 too, so legacy-only sessions still surfaced 已过期. Made the metadata accessor source-aware and lifted it from the view to
SessionStore.resolvedMetadata:.aior.manual) → V1 snapshot path with full stale fidelityisStale=false(matchesStaleState.legacyUnknown)Regression test
testLegacyOnlyMetadataNeverStalefirst asserts the V1 stale signal is true (the bug surface) then asserts the source-aware accessor overrules it.Verification per commit
swift build+swift test+xcodebuild build+xcodebuild test, all green at every commit.Final counts: 442 SPM tests, 341 Xcode tests.
Commits