Skip to content

v0.2.9 P1 — Data model + Display policy + Migration - #26

Merged
MedivhStory merged 10 commits into
mainfrom
feature/v0.2.9-p1-data-model
May 4, 2026
Merged

MedivhStory merged 10 commits into
mainfrom
feature/v0.2.9-p1-data-model

Conversation

@MedivhStory

Copy link
Copy Markdown
Owner

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)

  • New data model: UnderstandingArtifact, SelectionEvent (with previousVersionID + targetVersionID), RationaleMetadata, LegacyUnderstandingSnapshot, UnderstandingState
  • New persistence: UnderstandingStoreV2 at understanding-v2.json with schema version "1", per-field version chains, current pointers, selection-event log
  • Read-only legacy adapter over understanding.json (V1) — never writes, lenient decoding for partial records
  • UnderstandingDisplayPolicy pure resolver with precedence:
    • Title: Manual(v2) > AI(v2) > Legacy.title > Rule > UUID prefix
    • Progress: Manual(v2) > AI(v2) > Legacy.progress > Rule/derived
    • Summary: AI(v2) > Legacy.summary > nil
  • SessionStore wiring with dual-write: AI enhance writes both V1 (existing path, downgrade-safe) and V2 (new authoritative)
  • Source-aware resolvedMetadata(for:) accessor used by the AI panel
  • Minimal UI: source chips (AI / 规则 / Legacy / 手动) + Pre-v0.2.9 baseline hint

Out of scope (deferred to P2-P7)

  • Edit / regenerate-per-field UI (P2)
  • Full per-field staleness UI + history drawer (P3)
  • Evidence layer (P4)
  • Long-session input strategy + prompt rewrite (P5)
  • Desensitization extension + real-sample fixtures (P6)
  • Rationale generation + storage (P7)

Pointer movement rules (from C2)

  • Manual artifact: pointer always moves
  • AI/Rule artifact: pointer moves only when current is nil or non-manual (manual override is preserved)
  • Summary AI always moves (no manual path for summary)
  • setCurrentPointer validates target exists; throws versionNotFound and leaves state untouched on invalid ID

Migration (Hybrid)

  • understanding.json is never renamed or mutated by v0.2.9 reads
  • understanding-v2.json is created on first AI enhance
  • Legacy snapshots are derived at read time and remain available indefinitely
  • Dual-write keeps V1 in sync so pre-v0.2.9 builds see current data on downgrade

Two review iterations folded in

  1. 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 has accessibilityIdentifier staleMarker_<sessionID>.

  2. 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:

    • V2 ownership (any field source is .ai or .manual) → V1 snapshot path with full stale fidelity
    • Legacy-only → legacyAdapter path, force isStale=false (matches StaleState.legacyUnknown)
    • Otherwise → nil (panel hides the row)
      Regression test testLegacyOnlyMetadataNeverStale first 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

d285b48  fix: source-aware metadata accessor (legacy never stale)
ae4e1db  docs: PLAN-v0.2.9-P1 mirror cleanup
2f78c80  fix: restore LLMPanelView metadata + stale row
c9c1d56  C6 view migration + source chips
1ecaf42  C5 SessionStore dual-write wiring
215a4cd  C4 UnderstandingDisplayPolicy
a567eb4  C3 LegacyUnderstandingAdapter
e3207fe  C2 UnderstandingStoreV2 persistence
66b53f2  C1 v0.2.9 P1 model types
d04f28d  docs: PLAN-v0.2.9-P1

medivh_openclaw 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)
@MedivhStory
MedivhStory merged commit 94787f3 into main May 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant