# Post-v0.1.0 Stabilization Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** `v0.1.0..HEAD` 리뷰에서 발견한 production event log, compaction, fingerprint, stale/recovery, CI guard 결함을 실제 런타임 경로까지 닫는다.

**Architecture:** 먼저 deterministic build/test guard를 세운 뒤, session persistence를 event-log-backed production store로 연결한다. Compaction은 checkpoint 기반 replay로 안전하게 만들고, preview save는 server-side live recapture fingerprint 비교를 통과해야만 session mutation을 수행한다. Console pending recovery는 item 배열 대신 frozen preview context envelope을 저장한다.

**Tech Stack:** Kotlin/JVM 21, kotlinx.serialization, Java `File`/NIO atomic move, vanilla console JS concatenated by `scripts/build-console-assets.mjs`, Node built-in test runner, Gradle/Android/JUnit.

**Related spec:** [`../specs/2026-05-12-post-v0.1.0-stabilization-detailed-spec.md`](../specs/2026-05-12-post-v0.1.0-stabilization-detailed-spec.md)

---

## File Structure

- Modify: `scripts/build-console-assets.mjs` — normalize dynamic build header during `--check`.
- Modify: `.github/workflows/ci.yml` — add console asset check and JS pure tests.
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionPaths.kt` — add event/checkpoint path helpers.
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/tools/FixThisTools.kt` — wire production `EventLogWriter`/`EventLogReader`.
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogWriter.kt` — add checkpoint reader/writer or split into dedicated files.
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogCompactor.kt` — snapshot-before-archive compaction.
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionStore.kt` — checkpoint-aware boot replay.
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionService.kt` — make preview save path perform live recapture.
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackDraftService.kt` — keep mutation pure; accept already-computed fingerprint decision.
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewCaptureService.kt` — preserve SIF fields and real captured time.
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/SessionDomainMappers.kt` — round-trip SIF fields.
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/console/FeedbackItemRoutes.kt` — call suspend save route inside `runBlocking`.
- Modify: `fixthis-mcp/src/main/console/preview.js`, `annotations.js`, `pendingPersistence.js`, `main.js` — frozen timestamp and recovery envelope.
- Regenerate: `fixthis-mcp/src/main/resources/console/app.js` after every console JS edit.
- Test: add or update focused Kotlin tests under `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/` and console JS tests under `scripts/`.

---

## Task 1: Make Console Asset Check Deterministic

**Files:**
- Modify: `scripts/build-console-assets.mjs`
- Modify: `.github/workflows/ci.yml`
- Test: existing command-line checks

- [x] **Step 1: Reproduce current false failure**

Run:

```bash
node scripts/build-console-assets.mjs --check
```

Expected before fix: FAIL with `Generated console app.js is out of date` even when source modules did not change.

- [x] **Step 2: Normalize build header in `--check`**

In `scripts/build-console-assets.mjs`, add this helper near the build header construction:

```js
const buildHeaderRegex = /\/\/ build-header\nconst ConsoleBuildEpochMs = \d+;\nconst ConsoleBuildGitSha = '[^']+';\n/g;
const normalizedBuildHeader = "// build-header\nconst ConsoleBuildEpochMs = 0;\nconst ConsoleBuildGitSha = 'normalized';\n";

function normalizeBuildHeader(text) {
  return text.replace(buildHeaderRegex, normalizedBuildHeader);
}
```

Then change the `--check` comparison:

```js
const current = readFileSync(target, 'utf8');
if (normalizeBuildHeader(current) !== normalizeBuildHeader(output)) {
  console.error('Generated console app.js is out of date. Run node scripts/build-console-assets.mjs.');
  process.exit(1);
}
```

- [x] **Step 3: Add CI asset check and JS pure tests**

In `.github/workflows/ci.yml`, after `Check console JavaScript syntax`, add:

```yaml
      - name: Check console asset bundle
        run: node scripts/build-console-assets.mjs --check

      - name: Run console JavaScript tests
        run: |
          node --test \
            scripts/console-availability-test.mjs \
            scripts/pendingItemRecovery-test.mjs \
            scripts/beforeunloadGuard-test.mjs \
            scripts/undoRedo-test.mjs \
            scripts/undoKeymatch-test.mjs \
            scripts/activityDrift-test.mjs \
            scripts/previewStaleness-test.mjs
```

- [x] **Step 4: Verify**

Run:

```bash
node scripts/build-console-assets.mjs --check
node --check fixthis-mcp/src/main/resources/console/app.js
node --test \
  scripts/console-availability-test.mjs \
  scripts/pendingItemRecovery-test.mjs \
  scripts/beforeunloadGuard-test.mjs \
  scripts/undoRedo-test.mjs \
  scripts/undoKeymatch-test.mjs \
  scripts/activityDrift-test.mjs \
  scripts/previewStaleness-test.mjs
```

Expected: all commands PASS.

- [x] **Step 5: Commit**

```bash
git add scripts/build-console-assets.mjs .github/workflows/ci.yml
git commit -m "ci: guard console bundle and pure js tests"
```

## Task 2: Wire Event Log Into Production Store

**Files:**
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionPaths.kt`
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/tools/FixThisTools.kt`
- Test: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/tools/FixThisToolsEventLogIntegrationTest.kt`

- [x] **Step 1: Write failing integration test**

Create `FixThisToolsEventLogIntegrationTest.kt` with a test that constructs default `FixThisTools(projectRoot = tmp)`, calls the existing `fixthis_open_feedback_console` tool path with `newSession = true`, then calls the HTTP batch-save route against that console with a minimal preview fixture. Assert that the resulting session directory contains at least one event file:

```kotlin
val eventsRoot = tmp.resolve(".fixthis/feedback-sessions").toFile()
val jsonlFiles = eventsRoot.walkTopDown()
    .filter { it.isFile && it.extension == "jsonl" }
    .toList()
assertTrue(jsonlFiles.isNotEmpty(), "production FixThisTools must write event log files")
```

The test must enter through `FixThisTools`; do not construct `FeedbackSessionStore` directly here. Existing store tests already cover writer semantics, while this test protects the production wiring.

- [x] **Step 2: Run the focused test and confirm failure**

```bash
./gradlew :fixthis-mcp:test --tests "io.github.beyondwin.fixthis.mcp.tools.FixThisToolsEventLogIntegrationTest"
```

Expected: FAIL because no `events/*.jsonl` is created by default `FixThisTools`.

- [x] **Step 3: Add path helper**

In `FeedbackSessionPaths.kt`, add:

```kotlin
fun eventLogDirectory(sessionId: String): File = child(sessionDirectory(sessionId), "events")

fun eventLogCheckpointFile(sessionId: String): File = child(eventLogDirectory(sessionId), "checkpoint.json")
```

- [x] **Step 4: Wire default store in `FixThisTools`**

Add imports:

```kotlin
import io.github.beyondwin.fixthis.mcp.session.eventlog.EventLogReader
import io.github.beyondwin.fixthis.mcp.session.eventlog.EventLogWriter
```

Change constructor defaults so one `FeedbackSessionPaths` instance owns persistence and event paths:

```kotlin
private val feedbackSessionPaths: FeedbackSessionPaths = FeedbackSessionPaths(projectRoot),
private val feedbackService: FeedbackSessionService = FeedbackSessionService(
    bridge = bridge,
    store = FeedbackSessionStore(
        persistence = FeedbackSessionPersistence(feedbackSessionPaths),
        eventLogWriterProvider = { sessionId ->
            EventLogWriter(feedbackSessionPaths.eventLogDirectory(sessionId))
        },
        eventLogReaderProvider = { sessionId ->
            EventLogReader(feedbackSessionPaths.eventLogDirectory(sessionId))
        },
    ),
    projectRoot = projectRoot.absolutePath,
    defaultPackageName = defaultPackageName,
),
```

Preserve the existing injectable `feedbackService` parameter so tests can still pass custom stores.

- [x] **Step 5: Verify**

```bash
./gradlew :fixthis-mcp:test --tests "io.github.beyondwin.fixthis.mcp.tools.FixThisToolsEventLogIntegrationTest"
./gradlew :fixthis-mcp:test --tests "io.github.beyondwin.fixthis.mcp.session.FeedbackSessionStoreEventLogTest"
```

Expected: PASS.

- [x] **Step 6: Commit**

```bash
git add \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionPaths.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/tools/FixThisTools.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/tools/FixThisToolsEventLogIntegrationTest.kt
git commit -m "fix(mcp): wire event log into production feedback store"
```

## Task 3: Make Replay and Compaction Checkpoint-Aware

**Files:**
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogWriter.kt`
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogCompactor.kt`
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionStore.kt`
- Test: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogCompactorTest.kt`

- [x] **Step 1: Add failing compaction replay test**

Create a test that:

1. Creates a persistent session with 1,200 add/update events.
2. Captures the expected `SessionDto` before compaction.
3. Runs compaction with threshold 1,000.
4. Constructs a fresh `FeedbackSessionStore` with the same persistence and event reader.
5. Asserts the replayed session has the same `screens`, `items`, and `handoffBatches` as expected.

Core assertion:

```kotlin
assertEquals(expected.screens, replayed.screens)
assertEquals(expected.items, replayed.items)
assertEquals(expected.handoffBatches, replayed.handoffBatches)
```

Expected before fix: FAIL because replay resets state and only applies active, non-archived events.

- [x] **Step 2: Add checkpoint DTO**

Add to event log package:

```kotlin
@kotlinx.serialization.Serializable
data class EventLogCheckpoint(
    val schemaVersion: Int = 1,
    val sessionId: String,
    val compactedThroughSequenceNumber: Long,
    val snapshotUpdatedAtEpochMillis: Long,
    val createdAtEpochMillis: Long,
)
```

Add reader/writer methods that use temp file + atomic move, mirroring `FeedbackSessionPersistence.replaceFile` behavior.

- [x] **Step 3: Change replay mode**

In `FeedbackSessionStore.replaySessionEvents(sessionId)`:

```kotlin
val checkpoint = reader.readCheckpointOrNull()
var current = if (checkpoint == null) {
    shell.copy(screens = emptyList(), items = emptyList(), handoffBatches = emptyList())
} else {
    shell
}
val startingSeq = checkpoint?.compactedThroughSequenceNumber ?: -1L
var maxSeq = maxOf(lastReplayedSeq.getOrDefault(sessionId, -1L), startingSeq)

for (event in events) {
    if (event.sequenceNumber <= maxSeq) continue
    current = applyEvent(current, event) ?: current
    maxSeq = event.sequenceNumber
}
```

Keep the existing idempotent guard. Do not read `events/archive/` during normal replay.

- [x] **Step 4: Change compactor ordering**

`EventLogCompactor.runOnce` should:

1. Read active event files.
2. If under threshold, return.
3. Determine `compactedThroughSequenceNumber` from `files.dropLast(threshold).last()`.
4. Persist the current `SessionDto` through `FeedbackSessionPersistence.save(session)` or a `snapshotWriter` callback that writes canonical `session.json`.
5. Write `checkpoint.json`.
6. Move compacted files into `events/archive/`.

Do not write `state.json`; it is not the canonical persistence file in this repo.

- [x] **Step 5: Verify**

```bash
./gradlew :fixthis-mcp:test --tests "io.github.beyondwin.fixthis.mcp.session.eventlog.EventLogCompactorTest"
./gradlew :fixthis-mcp:test --tests "io.github.beyondwin.fixthis.mcp.session.FeedbackSessionStoreEventLogTest"
```

Expected: PASS.

- [x] **Step 6: Commit**

```bash
git add \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/ \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionStore.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogCompactorTest.kt
git commit -m "fix(mcp): make event log compaction checkpoint-aware"
```

## Task 4: Preserve Snapshot Integrity Fields End-to-End

**Files:**
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewCaptureService.kt`
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/SessionDomainMappers.kt`
- Test: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/SnapshotDtoSerializationTest.kt`
- Test: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewCaptureServiceMappingTest.kt`

- [x] **Step 1: Add failing mapper assertions**

Extend existing snapshot tests to assert that `orientation`, `widthPx`, `heightPx`, `densityDpi`, `windowMode`, `systemUiVisible`, `systemUiKind`, and `fingerprint` survive:

```kotlin
val dto = SnapshotDto(
    screenId = "s1",
    capturedAtEpochMillis = 123L,
    displayName = "Screen",
    orientation = "PORTRAIT",
    widthPx = 1080,
    heightPx = 2400,
    densityDpi = 440,
    windowMode = "FULLSCREEN",
    systemUiVisible = false,
    systemUiKind = null,
    fingerprint = "abc123def4567890",
)
val roundTrip = dto.toDomainSnapshot().toSnapshotDto()
assertEquals(dto.orientation, roundTrip.orientation)
assertEquals(dto.widthPx, roundTrip.widthPx)
assertEquals(dto.fingerprint, roundTrip.fingerprint)
```

Expected before fix: FAIL because `SessionDomainMappers.kt` drops these fields.

- [x] **Step 2: Update domain mappers**

Map all SIF fields in both directions. If domain types are enums, convert string values defensively:

```kotlin
orientation = orientation?.let { runCatching { ScreenOrientation.valueOf(it) }.getOrNull() },
windowMode = windowMode?.let { runCatching { WindowMode.valueOf(it) }.getOrNull() },
```

When mapping domain to DTO:

```kotlin
orientation = orientation?.name,
windowMode = windowMode?.name,
systemUiVisible = systemUiVisible,
systemUiKind = systemUiKind,
fingerprint = fingerprint,
```

- [x] **Step 3: Preserve bridge payload in `toCapturedScreen()`**

In `PreviewCaptureService.kt`, read top-level bridge fields:

```kotlin
val capturedAt = this["capturedAtEpochMillis"]?.jsonPrimitive?.longOrNull ?: System.currentTimeMillis()
val orientation = this["orientation"]?.jsonPrimitive?.contentOrNull
val widthPx = this["widthPx"]?.jsonPrimitive?.intOrNull
val heightPx = this["heightPx"]?.jsonPrimitive?.intOrNull
val densityDpi = this["densityDpi"]?.jsonPrimitive?.intOrNull
val windowMode = this["windowMode"]?.jsonPrimitive?.contentOrNull
val systemUiVisible = this["systemUiVisible"]?.jsonPrimitive?.booleanOrNull
val systemUiKind = this["systemUiKind"]?.jsonPrimitive?.contentOrNull
val fingerprint = this["fingerprint"]?.jsonPrimitive?.contentOrNull
```

Populate the same fields on `SnapshotDto`. If `fingerprint` is null but enough fields exist, compute it through `SnapshotFingerprint.compute(snapshotDto.toDomainSnapshot())` after mapper preservation is in place.

- [x] **Step 4: Verify**

```bash
./gradlew :fixthis-compose-core:test :fixthis-mcp:test --tests "*Snapshot*"
```

Expected: PASS.

- [x] **Step 5: Commit**

```bash
git add \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewCaptureService.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/SessionDomainMappers.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/
git commit -m "fix(sif): preserve snapshot integrity fields end to end"
```

## Task 5: Enforce Live Fingerprint on Save

**Files:**
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionService.kt`
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/AnnotationRepository.kt`
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/console/FeedbackItemRoutes.kt`
- Test: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackDraftServiceMismatchTest.kt`
- Test: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/console/FeedbackConsoleServerTest.kt`

- [x] **Step 1: Add failing route/service test**

Add a test where request `screen.fingerprint == "frozen"` but a fake bridge recapture returns `fingerprint == "current"`. The save route must return HTTP 409:

```kotlin
assertEquals(409, response.code)
assertTrue(response.body.contains("screen_fingerprint_mismatch"))
assertTrue(response.body.contains("frozen"))
assertTrue(response.body.contains("current"))
```

Expected before fix: FAIL because route passes `request.screen?.fingerprint` as current.

- [x] **Step 2: Move current fingerprint ownership to service**

Make `FeedbackSessionService.savePreviewFeedbackItems` suspend:

```kotlin
suspend fun savePreviewFeedbackItems(...): SessionDto {
    val session = registry.getSession(sessionId)
    val currentScreen = previewCaptureService.captureCurrentScreenForFingerprint(session)
    return annotations.savePreviewFeedbackItems(
        sessionId = sessionId,
        previewId = previewId,
        items = items,
        fallbackScreen = fallbackScreen,
        frozenFingerprint = frozenFingerprint,
        currentFingerprint = currentScreen.fingerprint,
        forceMismatchOverride = forceMismatchOverride,
    )
}
```

Keep `FeedbackDraftService.savePreviewFeedbackItems` non-suspend and pure; it should only compare the two fingerprints it receives.

- [x] **Step 3: Add lightweight current capture helper**

In `PreviewCaptureService`, add:

```kotlin
suspend fun captureCurrentScreenForFingerprint(session: SessionDto): SnapshotDto {
    val probeId = store.nextId()
    val artifactDirectory = File(
        session.projectRoot,
        ".fixthis/preview-cache/${session.sessionId}/fingerprint-$probeId",
    )
    val payload = bridge.captureScreenSnapshot(
        packageName = session.packageName,
        sessionId = session.sessionId,
        screenId = probeId,
        destinationDirectory = artifactDirectory,
    )
    return payload.toCapturedScreen(screenId = probeId, fallbackDisplayName = "Current screen")
}
```

If the helper creates screenshot artifacts only for fingerprinting, delete the directory in a `finally` block after the DTO is built.

- [x] **Step 4: Update HTTP route**

In `FeedbackItemRoutes.kt`, wrap the save call:

```kotlin
val session = try {
    runBlocking {
        service.savePreviewFeedbackItems(
            sessionId = service.requireCurrentSession().sessionId,
            previewId = request.previewId,
            items = request.items,
            fallbackScreen = request.screen,
            frozenFingerprint = request.frozenFingerprint,
            forceMismatchOverride = request.forceMismatchOverride,
        )
    }
}
```

Remove `currentFingerprint = request.screen?.fingerprint`.

- [x] **Step 5: Verify**

```bash
./gradlew :fixthis-mcp:test --tests "*Mismatch*" --tests "*FeedbackConsoleServerTest*"
```

Expected: PASS.

- [x] **Step 6: Commit**

```bash
git add \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionService.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/AnnotationRepository.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewCaptureService.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/console/FeedbackItemRoutes.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/
git commit -m "fix(sif): compare frozen preview against live screen fingerprint"
```

## Task 6: Persist Frozen Preview Timestamp and Recovery Envelope

**Files:**
- Modify: `fixthis-mcp/src/main/console/preview.js`
- Modify: `fixthis-mcp/src/main/console/annotations.js`
- Modify: `fixthis-mcp/src/main/console/pendingPersistence.js`
- Modify: `fixthis-mcp/src/main/console/main.js`
- Regenerate: `fixthis-mcp/src/main/resources/console/app.js`
- Test: `scripts/pendingItemRecovery-test.mjs`
- Test: `scripts/previewStaleness-test.mjs`

- [x] **Step 1: Add failing JS tests**

Extend `pendingItemRecovery-test.mjs` with an envelope test:

```js
m.persistPendingState('s1', {
  sessionId: 's1',
  previewId: 'p1',
  screen: { screenId: 'screen-1', fingerprint: 'fp' },
  screenshotUrl: '/api/previews/p1/screenshot',
  frozenAtEpochMillis: 1778572260000,
  items: [{ annotationId: 'local-1', comment: 'recovered' }],
});
const restored = m.restorePendingState('s1');
assert.equal(restored.previewId, 'p1');
assert.equal(restored.items.length, 1);
assert.equal(restored.screen.screenId, 'screen-1');
```

Extend `previewStaleness-test.mjs` to assert that a preview with `frozenAtEpochMillis` older than 30 seconds returns stale.

- [x] **Step 2: Implement envelope helpers**

In `pendingPersistence.js`, keep backward compatibility:

```js
function persistPendingState(sessionId, value) {
  if (!sessionId || typeof localStorage === 'undefined') return;
  const envelope = {
    schemaVersion: 1,
    sessionId,
    previewId: value?.previewId ?? null,
    screen: value?.screen ?? null,
    screenshotUrl: value?.screenshotUrl ?? null,
    frozenAtEpochMillis: value?.frozenAtEpochMillis ?? Date.now(),
    items: Array.isArray(value?.items) ? value.items : [],
  };
  localStorage.setItem(pendingKey(sessionId), JSON.stringify(envelope));
}

function restorePendingState(sessionId) {
  const raw = localStorage.getItem(pendingKey(sessionId));
  if (!raw) return null;
  const parsed = JSON.parse(raw);
  if (Array.isArray(parsed)) {
    return { schemaVersion: 0, sessionId, previewId: null, screen: null, screenshotUrl: null, frozenAtEpochMillis: null, items: parsed };
  }
  return parsed && Array.isArray(parsed.items) ? parsed : null;
}
```

Keep `persistPendingItems`/`restorePendingItems` as wrappers if existing call sites still use them.

- [x] **Step 3: Set frozen timestamp**

In `preview.js`:

```js
state.preview = {
  ...preview,
  activity: state.connection?.availability?.activity ?? null,
  frozenAtEpochMillis: Date.now(),
  stale: false,
};
```

In `annotations.js` when creating `addItemsFlow`:

```js
frozenAtEpochMillis: state.preview.frozenAtEpochMillis ?? Date.now(),
```

- [x] **Step 4: Persist full context on pending changes**

Replace item-only persistence calls with:

```js
persistPendingState(state.session?.sessionId, {
  previewId: addItemsFlow?.previewId ?? null,
  screen: addItemsFlow?.screen ?? null,
  screenshotUrl: addItemsFlow?.screenshotUrl ?? null,
  frozenAtEpochMillis: addItemsFlow?.frozenAtEpochMillis ?? null,
  items: pendingFeedbackItems,
});
```

- [x] **Step 5: Restore explicitly**

In `main.js`, restore into a separate variable first:

```js
pendingRecovery = restorePendingState(state.session.sessionId);
```

Only after user chooses Recover:

```js
addItemsFlow = {
  previewId: pendingRecovery.previewId,
  screen: pendingRecovery.screen,
  screenshotUrl: pendingRecovery.screenshotUrl,
  frozenAtEpochMillis: pendingRecovery.frozenAtEpochMillis,
  activity: pendingRecovery.screen?.activityName ?? null,
  activityDriftWarning: null,
};
pendingFeedbackItems = pendingRecovery.items;
```

For `schemaVersion === 0`, do not enable direct Save to MCP; route user through recapture/discard.

- [x] **Step 6: Rebuild and verify**

```bash
node scripts/build-console-assets.mjs
node scripts/build-console-assets.mjs --check
node --check fixthis-mcp/src/main/resources/console/app.js
node --test scripts/pendingItemRecovery-test.mjs scripts/previewStaleness-test.mjs
```

Expected: PASS.

- [x] **Step 7: Commit**

```bash
git add \
  fixthis-mcp/src/main/console/preview.js \
  fixthis-mcp/src/main/console/annotations.js \
  fixthis-mcp/src/main/console/pendingPersistence.js \
  fixthis-mcp/src/main/console/main.js \
  fixthis-mcp/src/main/resources/console/app.js \
  scripts/pendingItemRecovery-test.mjs \
  scripts/previewStaleness-test.mjs
git commit -m "fix(console): recover pending annotations with frozen preview context"
```

## Task 7: Clear Gradle Deprecation Warning

**Files:**
- Inspect: `build.gradle.kts`
- Inspect: `fixthis-gradle-plugin/build.gradle.kts`
- Modify: the exact file reported by `./gradlew help --warning-mode all --stacktrace --no-daemon`

- [x] **Step 1: Capture exact warning site**

Run:

```bash
./gradlew help --warning-mode all --no-daemon
```

Expected before fix: warning under `:fixthis-gradle-plugin` mentioning `ReportingExtension.file(String)` removal in Gradle 10.

- [x] **Step 2: Update the offending DSL**

Use the stack/warning output to locate the exact plugin or report configuration. Replace deprecated `ReportingExtension.file(String)` style with provider/layout APIs, for example:

```kotlin
layout.buildDirectory.file("reports/fixthis-gradle-plugin.html")
```

or the specific replacement named by Gradle for the reported call site.

- [x] **Step 3: Verify warning is gone**

```bash
./gradlew help --warning-mode all --no-daemon
```

Expected: no Gradle deprecation warning.

- [x] **Step 4: Commit**

```bash
git add build.gradle.kts fixthis-gradle-plugin/build.gradle.kts
git commit -m "build: remove Gradle 10 deprecation warning"
```

## Final Verification

- [x] **Step 1: Run full project verification**

```bash
./gradlew \
  spotlessCheck \
  detekt \
  :fixthis-compose-core:test \
  :fixthis-cli:test \
  :fixthis-mcp:test \
  :fixthis-compose-sidekick:testDebugUnitTest \
  :fixthis-gradle-plugin:test \
  :app:assembleDebug \
  :fixthis-cli:installDist \
  :fixthis-mcp:installDist \
  --no-daemon
```

Expected: BUILD SUCCESSFUL.

- [x] **Step 2: Run JS and asset verification**

```bash
node scripts/build-console-assets.mjs --check
node --check fixthis-mcp/src/main/resources/console/app.js
node --test \
  scripts/console-availability-test.mjs \
  scripts/pendingItemRecovery-test.mjs \
  scripts/beforeunloadGuard-test.mjs \
  scripts/undoRedo-test.mjs \
  scripts/undoKeymatch-test.mjs \
  scripts/activityDrift-test.mjs \
  scripts/previewStaleness-test.mjs
```

Expected: all tests pass.

- [x] **Step 3: Run whitespace and warning checks**

```bash
git diff --check
./gradlew help --warning-mode all --no-daemon
```

Expected: no whitespace errors, no Gradle deprecation warning.

- [x] **Step 4: Commit final verification note if docs/checklists changed**

```bash
git status --short
```

Expected: clean after all commits.
