# Post-v0.1 Review Hardening 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:** Close the post-`v0.1.0` review defects around event replay ordering, server-owned fingerprint checks, draft cleanup, compaction wiring, CI test coverage, harness skip reporting, preview cache retention, and the `esbuild` advisory.

**Architecture:** Keep the existing local-first architecture and compatibility contracts. Fix server trust boundaries in Kotlin services, keep browser draft workspace cleanup inside the existing draft workspace ports/use cases, and make CI/harness reporting read from existing manifests instead of duplicated lists. Event log compaction remains snapshot/checkpoint based and is triggered from production store wiring after successful mutations.

**Tech Stack:** Kotlin/JVM 21, kotlinx.serialization, JUnit/kotlin.test, vanilla browser JavaScript, Node 20+ built-in test runner, Playwright harness scripts, Gradle, npm.

**Related spec:** [`../specs/2026-05-15-post-v0.1-review-hardening-detailed-spec.md`](../specs/2026-05-15-post-v0.1-review-hardening-detailed-spec.md)

---

## File Structure

Modify these Kotlin files:

- `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogWriter.kt` - sort decoded events by `sequenceNumber`; compute max active sequence from decoded events.
- `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogCompactor.kt` - choose archive candidates by decoded `sequenceNumber`.
- `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/SessionReplayEngine.kt` - rely on sequence-ordered reader output and keep idempotent guard.
- `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionService.kt` - use server-owned frozen fingerprint from save reservation.
- `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackDraftService.kt` - expose reservation frozen fingerprint metadata and persist fingerprint source metadata.
- `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionStore.kt` - add optional compaction callback after successful event-backed mutations.
- `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/tools/FixThisTools.kt` - wire production compactor threshold/provider.
- `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewCaptureService.kt` - run preview cache disk retention cleanup after capture.
- Create `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewCacheRetentionPolicy.kt` if cleanup logic exceeds a small helper.

Modify these console JS files:

- `fixthis-mcp/src/main/console/state.js` - centralize empty workspace storage cleanup.
- `fixthis-mcp/src/main/console/annotations.js` - ensure cancel and last-delete call schema v2 workspace delete.
- `fixthis-mcp/src/main/console/draftUseCases.js` - reuse discard behavior for cancel/clear boundaries if needed.
- `fixthis-mcp/src/main/resources/console/app.js` and `.map` - regenerate only with `node scripts/build-console-assets.mjs`.

Modify these scripts and CI files:

- `.github/workflows/ci.yml` - run `npm run console:test:all` instead of hard-coded file list.
- `scripts/console-tests.json` - add a `harness` group or keep harness unit tests under a named npm script.
- `package.json` and `package-lock.json` - add any new npm scripts and update `esbuild`.
- `scripts/console-harness.mjs` - add explicit skip status and exit policy flags.
- `scripts/console-harness.test.mjs` - test skip JUnit and exit policy.
- `docs/contributing/release-readiness.md` - only if `esbuild` cannot be upgraded immediately.

Add or update these tests:

- `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionStoreEventLogTest.kt`
- `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogCompactorTest.kt`
- `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackDraftServiceMismatchTest.kt`
- `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionServiceTest.kt`
- `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewSnapshotCacheTest.kt`
- `scripts/draftStorageAdapter-test.mjs`
- `scripts/pendingItemRecovery-test.mjs`
- `scripts/console-harness.test.mjs`

## Conventions

- Do not rename persisted MCP JSON fields: `items`, `screens`, `itemId`, `screenId`, `targetEvidence`, `sourceCandidates`.
- Do not commit `.fixthis/`.
- Do not hand-edit `fixthis-mcp/src/main/resources/console/app.js`; regenerate it.
- Use focused tests after each task, then run the final matrix.
- Commit after each task when executing the plan.

---

### Task 1: Make Event Log Replay Sequence-Ordered

**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`
- Test: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionStoreEventLogTest.kt`
- Test: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogCompactorTest.kt`

- [ ] **Step 1: Add failing replay regression test**

Add this test to `FeedbackSessionStoreEventLogTest`:

```kotlin
@Test
fun bootReplayOrdersEventsBySequenceNumberWhenClockMovesBackward() {
    val tmp = Files.createTempDirectory("alh-test-clock-regression").toFile()
    try {
        val projectBase = File(tmp, "project")
        val paths = FeedbackSessionPaths(projectBase)
        val persistence = FeedbackSessionPersistence(paths)
        val evtBase = File(tmp, "events")
        var idSeq = 0
        val sharedIdGen: () -> String = { "id-${++idSeq}" }
        val (sid, screenId) = setupStore1WithThreeItems(evtBase, persistence, sharedIdGen)

        val writer = EventLogWriter(eventsDir(evtBase, sid))
        writer.append(
            SessionEvent(
                eventId = "manual-late-wall-clock",
                sequenceNumber = 4L,
                epochMillis = 3_000L,
                actor = "test",
                type = "addItem",
                payload = buildJsonObject {
                    put("item", testJson.encodeToJsonElement(makeDraftItem(screenId, "delta").copy(itemId = "manual-delta")))
                },
            ),
        )
        writer.append(
            SessionEvent(
                eventId = "manual-earlier-wall-clock",
                sequenceNumber = 5L,
                epochMillis = 1_000L,
                actor = "test",
                type = "addItem",
                payload = buildJsonObject {
                    put("item", testJson.encodeToJsonElement(makeDraftItem(screenId, "epsilon").copy(itemId = "manual-epsilon")))
                },
            ),
        )

        val store2 = FeedbackSessionStore(
            clock = { 4_000L },
            idGenerator = { "new-${++idSeq}" },
            persistence = persistence,
            eventLogWriterProvider = writerFor(evtBase),
            eventLogReaderProvider = readerFor(evtBase),
        )

        val comments = store2.getSession(sid).items.map { it.comment }.toSet()
        assertTrue("delta" in comments)
        assertTrue("epsilon" in comments)
    } finally {
        tmp.deleteRecursively()
    }
}
```

Run:

```bash
./gradlew :fixthis-mcp:test --tests "io.github.beyondwin.fixthis.mcp.session.FeedbackSessionStoreEventLogTest.bootReplayOrdersEventsBySequenceNumberWhenClockMovesBackward" --no-daemon
```

Expected before fix: FAIL because filename sorting can place seq 5 before seq 4 and cause seq 4 to be skipped.

- [ ] **Step 2: Sort decoded events by sequence number**

In `EventLogReader.readAll()`, replace filename-based ordering with decode then sort:

```kotlin
fun readAll(): List<SessionEvent> {
    val files = directory.listFiles { f -> f.extension == "jsonl" } ?: return emptyList()
    return files
        .map { file ->
            val line = file.readText(Charsets.UTF_8).trimEnd()
            eventLogJson.decodeFromString(SessionEvent.serializer(), line)
        }
        .sortedBy { it.sequenceNumber }
}
```

In `EventLogReader.maxActiveSequenceNumberOrNull()`, use decoded events:

```kotlin
fun maxActiveSequenceNumberOrNull(): Long? = readAll().maxOfOrNull { it.sequenceNumber }
```

- [ ] **Step 3: Add failing compactor ordering test**

Add this test to `EventLogCompactorTest`:

```kotlin
@Test
fun compactionArchivesLowestSequenceNumbersWhenWallClockOrderDiffers() {
    val dir = Files.createTempDirectory("compactor-sequence-order").toFile()
    try {
        val eventsDir = File(dir, "events")
        val writer = EventLogWriter(eventsDir, durability = EventLogDurability.Fast)
        writer.append(makeEvent(seq = 1L).copy(epochMillis = 3_000L))
        writer.append(makeEvent(seq = 2L).copy(epochMillis = 1_000L))
        writer.append(makeEvent(seq = 3L).copy(epochMillis = 2_000L))

        EventLogCompactor(
            eventsDir,
            snapshotProvider = { makeSession(updatedAtEpochMillis = 4_000L) },
            snapshotWriter = {},
            clock = { 5_000L },
        ).runOnce(threshold = 1)

        val checkpoint = EventLogReader(eventsDir).readCheckpointOrNull()
        assertEquals(2L, checkpoint?.compactedThroughSequenceNumber)
        val remaining = EventLogReader(eventsDir).readAll().map { it.sequenceNumber }
        assertEquals(listOf(3L), remaining)
    } finally {
        dir.deleteRecursively()
    }
}
```

- [ ] **Step 4: Make compactor sequence-aware**

In `EventLogCompactor.runOnce()`, decode event with file and sort by `sequenceNumber`:

```kotlin
private data class EventLogFile(val file: File, val event: SessionEvent)

fun runOnce(threshold: Int = 1000) {
    val files = directory.listFiles { f -> f.isFile && f.extension == "jsonl" }
        ?.map { file -> EventLogFile(file, readEvent(file)) }
        ?.sortedBy { it.event.sequenceNumber }
        .orEmpty()
    if (files.size <= threshold) return
    val toArchive = files.dropLast(threshold)
    val compactedThroughSequenceNumber = toArchive.last().event.sequenceNumber
    val snapshot = snapshotProvider()
    snapshotWriter(snapshot)
    EventLogWriter(directory).writeCheckpoint(
        EventLogCheckpoint(
            sessionId = snapshot.sessionId,
            compactedThroughSequenceNumber = compactedThroughSequenceNumber,
            snapshotUpdatedAtEpochMillis = snapshot.updatedAtEpochMillis,
            createdAtEpochMillis = clock(),
        ),
    )
    val archive = File(directory, "archive").apply { mkdirs() }
    toArchive.forEach { entry ->
        entry.file.renameTo(File(archive, entry.file.name))
    }
}

private fun readEvent(file: File): SessionEvent {
    val line = file.readText(Charsets.UTF_8).trimEnd()
    return compactorJson.decodeFromString(SessionEvent.serializer(), line)
}
```

Remove or inline the old `readSequenceNumber()` helper.

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

Run:

```bash
./gradlew :fixthis-mcp:test --tests "*EventLog*" --no-daemon
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogWriter.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogCompactor.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionStoreEventLogTest.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogCompactorTest.kt
git commit -m "fix(mcp): replay event logs by sequence"
```

### Task 2: Use Server-Owned Frozen Fingerprint

**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/FeedbackDraftService.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/session/FeedbackSessionServiceTest.kt`

- [ ] **Step 1: Add failing service-level tamper test**

Add a test in `FeedbackSessionServiceTest` near existing preview save tests. Use a fake bridge that returns one fingerprint on preview capture and a different fingerprint on save recapture:

```kotlin
@Test
fun liveSaveUsesCachedPreviewFingerprintWhenClientOmitsFrozenFingerprint() = runBlocking {
    val root = tempDir(prefix = "fixthis-server-fingerprint-")
    try {
        val bridge = FakeFixThisBridge(
            snapshotMutator = { callIndex, json ->
                if (callIndex == 1) json.withFingerprint("frozen-server")
                else json.withFingerprint("current-live")
            },
        )
        val service = FeedbackSessionService(
            bridge = bridge,
            store = FeedbackSessionStore(idGenerator = sequenceIds("session-1", "preview-1", "screen-1")),
            projectRoot = root.absolutePath,
            defaultPackageName = "io.github.beyondwin.fixthis.sample",
        )
        val session = service.openSession(null, newSession = true)
        val preview = service.capturePreview(session.sessionId)

        val error = assertFailsWith<ScreenFingerprintMismatch> {
            service.savePreviewFeedbackItemsWithLiveFingerprintMetadata(
                PreviewFeedbackLiveSaveRequest(
                    sessionId = session.sessionId,
                    previewId = preview.previewId,
                    items = listOf(validAreaDraft()),
                    fallbackScreen = preview.screen,
                    fingerprintCheck = PreviewFeedbackFingerprintCheck(
                        frozenFingerprint = null,
                        forceMismatchOverride = false,
                    ),
                ),
            )
        }

        assertEquals("frozen-server", error.frozenFingerprint)
        assertEquals("current-live", error.currentFingerprint)
    } finally {
        root.deleteRecursively()
    }
}
```

If `FakeFixThisBridge` does not currently support `snapshotMutator`, add a small test-only bridge subclass/helper in the test file rather than changing production bridge code.

- [ ] **Step 2: Add failing bogus-client-fingerprint test**

In the same test file, verify client cannot substitute current fingerprint as frozen:

```kotlin
@Test
fun liveSaveIgnoresClientFrozenFingerprintThatDiffersFromCachedPreview() = runBlocking {
    val root = tempDir(prefix = "fixthis-server-fingerprint-tamper-")
    try {
        val bridge = MutableFingerprintBridge(
            previewFingerprint = "frozen-server",
            currentFingerprint = "current-live",
        )
        val service = FeedbackSessionService(
            bridge = bridge,
            store = FeedbackSessionStore(idGenerator = sequenceIds("session-1", "preview-1", "screen-1")),
            projectRoot = root.absolutePath,
            defaultPackageName = "io.github.beyondwin.fixthis.sample",
        )
        val session = service.openSession(null, newSession = true)
        val preview = service.capturePreview(session.sessionId)

        assertFailsWith<ScreenFingerprintMismatch> {
            service.savePreviewFeedbackItemsWithLiveFingerprintMetadata(
                PreviewFeedbackLiveSaveRequest(
                    sessionId = session.sessionId,
                    previewId = preview.previewId,
                    items = listOf(validAreaDraft()),
                    fallbackScreen = preview.screen,
                    fingerprintCheck = PreviewFeedbackFingerprintCheck(
                        frozenFingerprint = "current-live",
                        forceMismatchOverride = false,
                    ),
                ),
            )
        }
    } finally {
        root.deleteRecursively()
    }
}
```

- [ ] **Step 3: Add server fingerprint source helper**

In `FeedbackDraftService.kt`, add:

```kotlin
internal data class PreviewFrozenFingerprint(
    val value: String?,
    val source: String,
)

internal fun PreviewFeedbackSaveReservation.serverFrozenFingerprint(): PreviewFrozenFingerprint {
    val cached = preview.snapshot.screen.fingerprint
    return PreviewFrozenFingerprint(
        value = cached,
        source = if (previewCacheBacked()) "previewCache" else "fallbackScreen",
    )
}
```

If `PreviewFeedbackSaveReservation` does not know whether `preview` came from cache, add `val fingerprintSource: String` to the reservation when creating it:

```kotlin
PreviewFeedbackSaveReservation(
    sessionId = sessionId,
    previewId = previewId,
    items = items,
    allowBlankComments = allowBlankComments,
    inFlightKey = slot.inFlightKey,
    preview = preview,
    frozenFingerprintSource = if (slot.cachedPreview != null) "previewCache" else "fallbackScreen",
)
```

- [ ] **Step 4: Commit with server-owned frozen fingerprint**

Change `FeedbackSessionService.savePreviewFeedbackItemsWithLiveFingerprintMetadata()` so commit receives the server frozen fingerprint:

```kotlin
val serverFrozenFingerprint = reservation.preview.snapshot.screen.fingerprint
return annotations.commitPreviewFeedbackSaveWithMetadata(
    reservation = reservation,
    frozenFingerprint = serverFrozenFingerprint,
    currentFingerprint = currentScreen.fingerprint,
    forceMismatchOverride = request.fingerprintCheck.forceMismatchOverride,
)
```

Do not pass `request.fingerprintCheck.frozenFingerprint` into the enforcement decision.

- [ ] **Step 5: Persist metadata about source and client mismatch**

Extend `previewSaveEventMetadata()`:

```kotlin
private fun previewSaveEventMetadata(
    forceMismatchOverride: Boolean,
    fingerprintUnavailableReason: String?,
    frozenFingerprintSource: String,
    clientFrozenFingerprintMismatched: Boolean,
): JsonObject = buildJsonObject {
    if (forceMismatchOverride) put("forceMismatchOverride", true)
    if (fingerprintUnavailableReason != null) put("fingerprintUnavailableReason", fingerprintUnavailableReason)
    put("frozenFingerprintSource", frozenFingerprintSource)
    if (clientFrozenFingerprintMismatched) put("clientFrozenFingerprintMismatched", true)
}
```

Thread `clientFrozenFingerprintMismatched` from `FeedbackSessionService` into the commit method if needed. Keep old direct `savePreviewFeedbackItems()` test API behavior by defaulting the new arguments.

- [ ] **Step 6: Verify task**

Run:

```bash
./gradlew :fixthis-mcp:test --tests "*FeedbackDraftServiceMismatchTest*" --tests "*FeedbackSessionServiceTest*" --no-daemon
```

Expected: PASS.

- [ ] **Step 7: 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/FeedbackDraftService.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackDraftServiceMismatchTest.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionServiceTest.kt
git commit -m "fix(mcp): trust server preview fingerprint on save"
```

### Task 3: Delete Schema v2 Draft Workspace on Discard and Last Delete

**Files:**
- Modify: `fixthis-mcp/src/main/console/state.js`
- Modify: `fixthis-mcp/src/main/console/annotations.js`
- Modify: `fixthis-mcp/src/main/resources/console/app.js`
- Test: `scripts/draftStorageAdapter-test.mjs`
- Test: `scripts/pendingItemRecovery-test.mjs`

- [ ] **Step 1: Add failing storage cleanup tests**

Add this test to `scripts/draftStorageAdapter-test.mjs`:

```js
test('deleteWorkspace removes stored workspace and index entry', () => {
  const localStorage = fakeLocalStorage();
  const adapter = m.createDraftStorageAdapter(localStorage);
  adapter.saveWorkspace({
    schemaVersion: 2,
    sessionId: 'session-a',
    workspaceId: 'ws-a',
    context: { sessionId: 'session-a' },
    items: [{ draftItemId: 'draft-1' }],
  });

  adapter.deleteWorkspace('session-a', 'ws-a');

  assert.equal(localStorage.getItem(m.draftWorkspaceKey('session-a', 'ws-a')), null);
  assert.deepEqual(adapter.loadWorkspacesForSession('session-a'), []);
});
```

This should already pass and confirms adapter behavior before wiring presentation cleanup.

- [ ] **Step 2: Add source contract test for reset cleanup**

In `scripts/pendingItemRecovery-test.mjs`, add:

```js
test('resetAnnotationComposerState deletes schema v2 workspace when clearing flow and mirror', () => {
  const resetBody = extractFunctionBody(annotationsSource, 'function resetAnnotationComposerState');
  assert.match(resetBody, /deleteCurrentDraftWorkspaceStorage\(\);/);
  assert.match(resetBody, /if \(clearFlow\) setDraftWorkspace\(createEmptyDraftWorkspace\(\)\);/);
});

test('deletePendingFeedbackItem deletes workspace storage when last draft item is removed', () => {
  const deleteBody = extractFunctionBody(annotationsSource, 'function deletePendingFeedbackItem');
  assert.match(deleteBody, /deleteCurrentDraftWorkspaceStorage\(\);/);
  assert.match(deleteBody, /nextWorkspace\.items\.length === 0/);
});
```

Run:

```bash
node --test scripts/pendingItemRecovery-test.mjs scripts/draftStorageAdapter-test.mjs
```

Expected before fix: FAIL on source contract test.

- [ ] **Step 3: Add central cleanup helper in `state.js`**

Add next to `persistCurrentDraftWorkspaceIfNeeded()`:

```js
function deleteCurrentDraftWorkspaceStorage() {
  if (!draftWorkspace?.workspaceId) return;
  const sessionId = draftWorkspace.context?.sessionId || state.session?.sessionId;
  if (!sessionId) return;
  const storage = createBrowserDraftPorts().storage;
  storage.deleteWorkspace(sessionId, draftWorkspace.workspaceId);
}
```

Change `persistCurrentDraftWorkspaceIfNeeded()` so empty workspaces delete:

```js
function persistCurrentDraftWorkspaceIfNeeded() {
  if (!draftWorkspace?.workspaceId) return;
  if (!(draftWorkspace.items || []).length) {
    deleteCurrentDraftWorkspaceStorage();
    return;
  }
  const storage = createBrowserDraftPorts().storage;
  storage.saveWorkspace(draftWorkspaceRecoveryEnvelope(draftWorkspace));
}
```

- [ ] **Step 4: Wire reset and last-delete cleanup**

In `resetAnnotationComposerState(clearFlow = true, clearMirror = true)`, before setting the empty workspace:

```js
if (clearFlow && clearMirror) deleteCurrentDraftWorkspaceStorage();
if (clearFlow) setDraftWorkspace(createEmptyDraftWorkspace());
```

In `deletePendingFeedbackItem(index)`, keep the old workspace until after computing the next one:

```js
const nextWorkspace = deleteDraftItem(draftWorkspace, removed.draftItemId);
if ((nextWorkspace.items || []).length === 0) {
  deleteCurrentDraftWorkspaceStorage();
}
setDraftWorkspace(nextWorkspace);
```

- [ ] **Step 5: Rebuild console assets and verify**

Run:

```bash
npm run console:pending:test
npm run console:draft:test
node scripts/build-console-assets.mjs
node scripts/build-console-assets.mjs --check
node --check fixthis-mcp/src/main/resources/console/app.js
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add \
  fixthis-mcp/src/main/console/state.js \
  fixthis-mcp/src/main/console/annotations.js \
  fixthis-mcp/src/main/resources/console/app.js \
  fixthis-mcp/src/main/resources/console/app.js.map \
  fixthis-mcp/src/main/resources/console/console-build-meta.json \
  scripts/draftStorageAdapter-test.mjs \
  scripts/pendingItemRecovery-test.mjs
git commit -m "fix(console): clear discarded draft workspaces"
```

### Task 4: Wire Event Log Compaction in Production

**Files:**
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionStore.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/session/eventlog/EventLogCompactorTest.kt`
- Test: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionStoreEventLogTest.kt`

- [ ] **Step 1: Add failing production wiring test**

Add a test to `EventLogCompactorTest` that constructs a store with persistence, event writer/reader, and compaction threshold 3. The exact constructor may be adjusted by Step 2:

```kotlin
@Test
fun storeRunsCompactionAfterSuccessfulMutationsWhenThresholdExceeded() {
    val projectRoot = Files.createTempDirectory("compactor-production-wire").toFile()
    try {
        val paths = FeedbackSessionPaths(projectRoot)
        val persistence = FeedbackSessionPersistence(paths)
        var now = 1_000L
        val store = FeedbackSessionStore(
            clock = { ++now },
            idGenerator = idGenerator(),
            persistence = persistence,
            eventLogWriterProvider = fastEventWriterFor(paths),
            eventLogReaderProvider = eventReaderFor(paths),
            eventLogCompactorProvider = { sessionId ->
                EventLogCompactor(
                    directory = paths.eventLogDirectory(sessionId),
                    snapshotProvider = { store.getSession(sessionId) },
                    snapshotWriter = { persistence.save(it) },
                    clock = { ++now },
                )
            },
            eventLogCompactionThreshold = 3,
        )
        val session = store.openSession("com.test", projectRoot.absolutePath)
        val screen = store.addScreen(session.sessionId, makeScreen())
        repeat(8) { index -> store.addItem(session.sessionId, makeDraftItem(screen.screenId, index + 1)) }

        val active = paths.eventLogDirectory(session.sessionId).listFiles { f -> f.isFile && f.extension == "jsonl" }.orEmpty()
        assertTrue(active.size <= 3)
        assertTrue(File(paths.eventLogDirectory(session.sessionId), "checkpoint.json").isFile)
        assertTrue(File(paths.eventLogDirectory(session.sessionId), "archive").isDirectory)
    } finally {
        projectRoot.deleteRecursively()
    }
}
```

Expected before fix: compile failure because store has no compactor provider.

- [ ] **Step 2: Add optional compactor provider to store**

In `FeedbackSessionStore` constructor:

```kotlin
private val eventLogCompactorProvider: ((sessionId: String) -> EventLogCompactor)? = null,
private val eventLogCompactionThreshold: Int = 1000,
```

Import `EventLogCompactor`.

Add helper:

```kotlin
@Suppress("TooGenericExceptionCaught", "SwallowedException")
private fun compactEventLogAfterMutation(sessionId: String) {
    val compactor = eventLogCompactorProvider?.invoke(sessionId) ?: return
    try {
        compactor.runOnce(eventLogCompactionThreshold)
    } catch (error: Exception) {
        replaySkippedSessions[sessionId] = SkippedFeedbackSession(
            sessionId = sessionId,
            path = "event-log-compaction",
            message = "Event log compaction failed: ${error.message ?: error::class.java.simpleName}",
        )
    }
}
```

Call it after successful `commitSessionMutation()` saves the updated session. If `commitSessionMutation()` is the single mutation funnel, place it there; otherwise call from each event-backed mutation after `save(updated)`.

- [ ] **Step 3: Wire default production compactor**

In `FixThisTools.defaultFeedbackSessionService()`, pass:

```kotlin
eventLogCompactorProvider = { sessionId ->
    EventLogCompactor(
        directory = feedbackSessionPaths.eventLogDirectory(sessionId),
        snapshotProvider = { store.getSession(sessionId) },
        snapshotWriter = { FeedbackSessionPersistence(feedbackSessionPaths).save(it) },
    )
},
```

If referencing `store` inside its own constructor is awkward, build persistence and store in local variables:

```kotlin
val persistence = FeedbackSessionPersistence(feedbackSessionPaths)
lateinit var store: FeedbackSessionStore
store = FeedbackSessionStore(
    persistence = persistence,
    eventLogWriterProvider = { sessionId -> EventLogWriter(feedbackSessionPaths.eventLogDirectory(sessionId)) },
    eventLogReaderProvider = { sessionId -> EventLogReader(feedbackSessionPaths.eventLogDirectory(sessionId)) },
    eventLogCompactorProvider = { sessionId ->
        EventLogCompactor(
            directory = feedbackSessionPaths.eventLogDirectory(sessionId),
            snapshotProvider = { store.getSession(sessionId) },
            snapshotWriter = { persistence.save(it) },
        )
    },
)
```

- [ ] **Step 4: Verify replay after compaction**

Run:

```bash
./gradlew :fixthis-mcp:test --tests "*EventLogCompactorTest*" --tests "*FeedbackSessionStoreEventLogTest*" --no-daemon
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionStore.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/tools/FixThisTools.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogCompactorTest.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionStoreEventLogTest.kt
git commit -m "fix(mcp): compact production event logs"
```

### Task 5: Make CI Use the Console Test Manifest

**Files:**
- Modify: `.github/workflows/ci.yml`
- Modify: `scripts/console-tests.json`
- Modify: `package.json`
- Test: `scripts/run-console-tests.mjs`

- [ ] **Step 1: Add harness group to test manifest**

In `scripts/console-tests.json`, add:

```json
"harness": [
  "scripts/console-fixture/fakeBridgeServer-test.mjs",
  "scripts/console-fixture/scenarios-test.mjs",
  "scripts/console-harness.test.mjs"
]
```

Ensure the previous `session` group remains unchanged.

- [ ] **Step 2: Add named npm script**

In `package.json` scripts:

```json
"console:harness:test": "node scripts/run-console-tests.mjs harness",
"console:test:all": "node scripts/run-console-tests.mjs availability pending beforeunload undo activity preview draft session harness"
```

- [ ] **Step 3: Update CI**

Replace the hard-coded `Run console JavaScript tests` step with:

```yaml
      - name: Run console JavaScript tests
        run: npm run console:test:all
```

- [ ] **Step 4: Verify**

Run:

```bash
npm run console:test:all
node scripts/run-console-tests.mjs harness
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add .github/workflows/ci.yml scripts/console-tests.json package.json
git commit -m "ci: run console tests from manifest"
```

### Task 6: Report Harness Skips Explicitly

**Files:**
- Modify: `scripts/console-harness.mjs`
- Modify: `scripts/console-harness.test.mjs`

- [ ] **Step 1: Add failing JUnit skip test**

In `scripts/console-harness.test.mjs`, add:

```js
test('emitJunit records skipped cells separately from passed cells', () => {
  const dir = mkdtempSync(join(tmpdir(), 'harness-skip-'));
  try {
    emitJunit(
      [{ scenarioKey: 'network-outage', viewportKey: 'mobile-390', ok: true, skipped: true, skipReason: '@blocked-pending-impl' }],
      dir
    );
    const xml = readFileSync(join(dir, 'results.xml'), 'utf8');
    assert.match(xml, /skipped="1"/);
    assert.match(xml, /<skipped message="@blocked-pending-impl" \/>/);
    assert.doesNotMatch(xml, /name="network-outage__mobile-390" \/>/);
  } finally {
    rmSync(dir, { recursive: true, force: true });
  }
});
```

- [ ] **Step 2: Add argument parsing test for skip policy**

Add:

```js
test('parseArgs supports explicit skip policy flags', () => {
  assert.equal(parseArgs(['node', 'script', '--allow-skips'], {}).allowSkips, true);
  assert.equal(parseArgs(['node', 'script', '--fail-on-skips'], {}).failOnSkips, true);
});
```

- [ ] **Step 3: Implement flags**

In `parseArgs()` defaults:

```js
allowSkips: false,
failOnSkips: false,
```

In flag parsing:

```js
else if (flag === '--allow-skips') { args.allowSkips = true; }
else if (flag === '--fail-on-skips') { args.failOnSkips = true; }
```

- [ ] **Step 4: Return skip reason and print `[SKIP]`**

In `runCell()`:

```js
return {
  scenarioKey,
  viewportKey,
  ok: true,
  skipped: true,
  skipReason: '@blocked-pending-impl',
};
```

In `main()` summary:

```js
const status = result.skipped ? 'SKIP' : result.ok ? 'PASS' : 'FAIL';
const suffix = result.skipped ? ` - ${result.skipReason}` : result.ok ? '' : ` - ${result.error}`;
console.log(`[${status}] ${result.scenarioKey} / ${result.viewportKey}${suffix}`);
```

- [ ] **Step 5: Emit JUnit skipped elements**

Change `emitJunit()`:

```js
const cases = results.map((r) => {
  const name = xmlEscape(`${r.scenarioKey}__${r.viewportKey}`);
  if (r.skipped) {
    return `    <testcase classname="console-harness" name="${name}">\n      <skipped message="${xmlEscape(r.skipReason || 'skipped')}" />\n    </testcase>`;
  }
  if (r.ok) return `    <testcase classname="console-harness" name="${name}" />`;
  return `    <testcase classname="console-harness" name="${name}">\n      <failure>${xmlEscape(r.error)}</failure>\n    </testcase>`;
}).join('\n');
const skipped = results.filter((r) => r.skipped).length;
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="console-harness" tests="${results.length}" failures="${results.filter((r) => !r.ok).length}" skipped="${skipped}">\n${cases}\n</testsuite>\n`;
```

- [ ] **Step 6: Enforce exit policy**

At the end of `main()`:

```js
const failed = results.filter((r) => !r.ok);
const skipped = results.filter((r) => r.skipped);
if (failed.length > 0) {
  process.exitCode = 1;
} else if (skipped.length > 0 && (args.failOnSkips || (!args.allowSkips && args.matrix !== 'all'))) {
  process.exitCode = 2;
}
```

- [ ] **Step 7: Verify**

Run:

```bash
node --test scripts/console-harness.test.mjs
node scripts/console-harness.mjs --matrix network-outage --viewport mobile-390; test "$?" -eq 2
node scripts/console-harness.mjs --matrix network-outage --viewport mobile-390 --allow-skips
```

Expected: first explicit blocked scenario exits 2, second exits 0 and records JUnit skipped.

- [ ] **Step 8: Commit**

```bash
git add scripts/console-harness.mjs scripts/console-harness.test.mjs
git commit -m "test(console): report harness skips explicitly"
```

### Task 7: Add Preview Cache Disk Retention Policy

**Files:**
- Create: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewCacheRetentionPolicy.kt`
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewCaptureService.kt`
- Test: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionServiceTest.kt`
- Test: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewSnapshotCacheTest.kt`

- [ ] **Step 1: Add failing retention test**

Add to `FeedbackSessionServiceTest`:

```kotlin
@Test
fun capturePreviewDeletesOldUnsavedPreviewDirectoriesAfterRetentionLimit() = runBlocking {
    val root = tempDir(prefix = "fixthis-preview-retention-")
    try {
        val store = FeedbackSessionStore(
            clock = { 1_000L },
            idGenerator = sequenceIds(
                "session-1",
                "preview-1", "screen-1",
                "preview-2", "screen-2",
                "preview-3", "screen-3",
                "preview-4", "screen-4",
            ),
        )
        val service = FeedbackSessionService(
            bridge = FakeFixThisBridge(),
            store = store,
            projectRoot = root.absolutePath,
            defaultPackageName = "io.github.beyondwin.fixthis.sample",
            previewCacheRetentionPolicy = PreviewCacheRetentionPolicy(
                maxDirectoriesPerSession = 2,
                minAgeMillis = 0L,
                clock = { 2_000L },
            ),
        )
        val session = service.openSession(null, newSession = true)

        repeat(4) { service.capturePreview(session.sessionId) }

        val previewRoot = root.resolve(".fixthis/preview-cache/${session.sessionId}")
        val names = previewRoot.listFiles().orEmpty().filter { it.isDirectory }.map { it.name }.sorted()
        assertEquals(listOf("preview-3", "preview-4"), names)
    } finally {
        root.deleteRecursively()
    }
}
```

Expected before fix: compile failure because no injectable retention policy exists.

- [ ] **Step 2: Create retention policy**

Create `PreviewCacheRetentionPolicy.kt`:

```kotlin
package io.github.beyondwin.fixthis.mcp.session

import java.io.File

class PreviewCacheRetentionPolicy(
    private val maxDirectoriesPerSession: Int = 30,
    private val minAgeMillis: Long = 10 * 60 * 1000L,
    private val clock: () -> Long = { System.currentTimeMillis() },
) {
    init {
        require(maxDirectoriesPerSession > 0) { "Preview cache directory limit must be positive" }
        require(minAgeMillis >= 0L) { "Preview cache minimum age must not be negative" }
    }

    fun cleanupSessionPreviewRoot(previewRoot: File) {
        val directories = previewRoot.listFiles()
            ?.filter { it.isDirectory && !it.name.startsWith("fingerprint-") }
            ?.sortedBy { it.lastModified() }
            .orEmpty()
        if (directories.size <= maxDirectoriesPerSession) return
        val now = clock()
        val removable = directories
            .dropLast(maxDirectoriesPerSession)
            .filter { dir -> now - dir.lastModified() >= minAgeMillis }
        removable.forEach { it.deleteRecursively() }
    }
}
```

- [ ] **Step 3: Inject policy through service**

Add constructor parameter to `FeedbackSessionService`:

```kotlin
previewCacheRetentionPolicy: PreviewCacheRetentionPolicy = PreviewCacheRetentionPolicy(),
```

Pass it into `PreviewCaptureService`.

Update `PreviewCaptureService` constructor:

```kotlin
private val previewCacheRetentionPolicy: PreviewCacheRetentionPolicy = PreviewCacheRetentionPolicy(),
```

After `previewCache.put(...)`, call cleanup:

```kotlin
val previewRoot = File(session.projectRoot, ".fixthis/preview-cache/${session.sessionId}").canonicalFile
previewCacheRetentionPolicy.cleanupSessionPreviewRoot(previewRoot)
```

Keep `captureCurrentScreenForFingerprint()` deleting `fingerprint-*` directories in `finally`.

- [ ] **Step 4: Preserve late screenshot fallback within TTL**

Keep or adjust existing test `capturePreviewRetainsEvictedPreviewCacheDirectoriesForLateScreenshotRequests` so it constructs default retention policy and still expects all four preview directories to exist. This protects the 10-minute default TTL.

- [ ] **Step 5: Verify**

Run:

```bash
./gradlew :fixthis-mcp:test --tests "*FeedbackSessionServiceTest*" --tests "*PreviewSnapshotCacheTest*" --no-daemon
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewCacheRetentionPolicy.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewCaptureService.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionService.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionServiceTest.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewSnapshotCacheTest.kt
git commit -m "fix(mcp): prune stale preview cache directories"
```

### Task 8: Resolve esbuild Advisory

**Files:**
- Modify: `package.json`
- Modify: `package-lock.json`
- Optional modify: `docs/contributing/release-readiness.md`

- [ ] **Step 1: Capture current advisory**

Run:

```bash
npm audit --json
```

Expected before fix: moderate advisory for `esbuild <=0.24.2`.

- [ ] **Step 2: Upgrade esbuild**

Run:

```bash
npm install --save-dev esbuild@^0.28.0
```

If `0.28.0` is unavailable in the registry at execution time, use the latest non-vulnerable version reported by `npm view esbuild version`.

- [ ] **Step 3: Verify bundle compatibility**

Run:

```bash
node --test scripts/build-console-assets-test.mjs
node scripts/build-console-assets.mjs
node scripts/build-console-assets.mjs --check
node --check fixthis-mcp/src/main/resources/console/app.js
npm run console:test:all
npm audit --json
```

Expected: tests pass, bundle budgets pass, audit no longer reports the esbuild advisory.

- [ ] **Step 4: If upgrade is blocked, document temporary accept**

Only if Step 3 cannot pass after reasonable fixes, add to `docs/contributing/release-readiness.md`:

```markdown
### Temporary Dependency Advisory Accept - esbuild

- Advisory: GHSA-67mh-4wv8-2f99
- Affected dependency: `esbuild@0.21.5`
- FixThis usage: build API for static console asset generation; no esbuild dev server is started by project scripts.
- Current decision: temporarily accepted until the bundle/test compatibility issue below is resolved.
- Blocker: [write exact failing command and error]
- Revisit date: 2026-06-15
```

- [ ] **Step 5: Commit**

```bash
git add package.json package-lock.json docs/contributing/release-readiness.md fixthis-mcp/src/main/resources/console/app.js fixthis-mcp/src/main/resources/console/app.js.map fixthis-mcp/src/main/resources/console/console-build-meta.json
git commit -m "chore(deps): update esbuild"
```

If no docs or generated bundle changed, omit those paths from `git add`.

### Task 9: Final Verification

**Files:**
- No code changes expected.

- [ ] **Step 1: Run Node verification**

```bash
npm ci
npm run console:test:all
node scripts/build-console-assets.mjs --check
node --check fixthis-mcp/src/main/resources/console/app.js
node scripts/check-doc-consistency.mjs
npm audit --json
```

Expected: all pass, or `npm audit` has only documented accepted findings.

- [ ] **Step 2: Run Gradle 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: PASS.

- [ ] **Step 3: Run whitespace check**

```bash
git diff --check
```

Expected: no output.

- [ ] **Step 4: Review changed files**

```bash
git status --short
git diff --stat
git diff -- docs/superpowers/specs/2026-05-15-post-v0.1-review-hardening-detailed-spec.md docs/superpowers/plans/2026-05-15-post-v0.1-review-hardening-implementation.md
```

Expected: only intended files changed.

- [ ] **Step 5: Final commit if earlier tasks were batched**

```bash
git add \
  .github/workflows/ci.yml \
  package.json \
  package-lock.json \
  scripts/console-tests.json \
  scripts/console-harness.mjs \
  scripts/console-harness.test.mjs \
  scripts/draftStorageAdapter-test.mjs \
  scripts/pendingItemRecovery-test.mjs \
  docs/contributing/release-readiness.md \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogWriter.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogCompactor.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/SessionReplayEngine.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionService.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackDraftService.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionStore.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewCaptureService.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewCacheRetentionPolicy.kt \
  fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/tools/FixThisTools.kt \
  fixthis-mcp/src/main/console/state.js \
  fixthis-mcp/src/main/console/annotations.js \
  fixthis-mcp/src/main/console/draftUseCases.js \
  fixthis-mcp/src/main/resources/console/app.js \
  fixthis-mcp/src/main/resources/console/app.js.map \
  fixthis-mcp/src/main/resources/console/console-build-meta.json \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionStoreEventLogTest.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/eventlog/EventLogCompactorTest.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackDraftServiceMismatchTest.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/FeedbackSessionServiceTest.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/session/PreviewSnapshotCacheTest.kt
git commit -m "fix: harden post-v0.1 feedback workflows"
```
