# Legacy sealseek Sessions Import — 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:** Convert legacy `~/.sealseek/workspaces/default/sessions/default_*.json` files into the new sealclaw on-disk layout so each old conversation appears as its own row in the session list and is readable end-to-end.

**Architecture:** A single `.mjs` Node script that exports pure helpers (legacy → transcript events, timestamp parsing, store merge, manifest) and a CLI shell that wires them together. Helpers are unit-tested under `test/scripts/`. Source files are untouched; all output lands under `~/.sealseek/agents/main/sessions/`.

**Tech Stack:** Node 22 ESM, only `node:fs/node:fs/promises/node:path/node:crypto`. Vitest + TypeScript for tests, importing the `.mjs` module directly (existing pattern: `test/scripts/audit-seams.test.ts` imports `scripts/audit-seams.mjs`).

**Spec:** [docs/superpowers/specs/2026-05-25-legacy-sessions-import-design.md](../specs/2026-05-25-legacy-sessions-import-design.md)

---

## File Structure

- **Create:** `scripts/migrate-legacy-sessions.mjs`
  - Pure helpers (exported): `convertLegacyMessages`, `parseLegacyTimestamp`, `buildStoreEntry`, `mergeSessionsStore`, `loadManifest`, `recordImport`, `runCli`.
  - CLI guard at bottom: when invoked as `node scripts/migrate-legacy-sessions.mjs ...`, calls `runCli({ argv: process.argv.slice(2), env: process.env })`.
- **Create:** `test/scripts/migrate-legacy-sessions.test.ts`
  - Vitest unit tests for each exported helper. No filesystem mocks for the merge/manifest tests — use `fs.mkdtempSync(os.tmpdir())` real temp dirs.

No other files. No imports from `src/`. No new dependencies.

---

## Task 1: Set up files and TDD `convertLegacyMessages`

**Files:**
- Create: `scripts/migrate-legacy-sessions.mjs`
- Create: `test/scripts/migrate-legacy-sessions.test.ts`

- [ ] **Step 1: Create the skeleton script with only exports stubs**

Write `scripts/migrate-legacy-sessions.mjs`:

```javascript
#!/usr/bin/env node
// Migrate legacy sealseek session JSON files into sealclaw's per-session
// JSONL + sessions.json layout. See
// docs/superpowers/specs/2026-05-25-legacy-sessions-import-design.md.

import crypto from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

export function convertLegacyMessages(_legacyJson, _opts) {
  throw new Error("not implemented");
}

export function parseLegacyTimestamp(_value, _fallbackMs) {
  throw new Error("not implemented");
}

export function buildStoreEntry(_args) {
  throw new Error("not implemented");
}

export function mergeSessionsStore(_args) {
  throw new Error("not implemented");
}

export function loadManifest(_manifestPath) {
  throw new Error("not implemented");
}

export function recordImport(_args) {
  throw new Error("not implemented");
}

export async function runCli(_args) {
  throw new Error("not implemented");
}

const invokedDirect =
  process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (invokedDirect) {
  runCli({ argv: process.argv.slice(2), env: process.env })
    .then((code) => process.exit(code ?? 0))
    .catch((err) => {
      console.error(err?.stack ?? String(err));
      process.exit(1);
    });
}
```

- [ ] **Step 2: Write failing tests for `convertLegacyMessages`**

Write `test/scripts/migrate-legacy-sessions.test.ts`:

```typescript
import { describe, expect, it } from "vitest";
import { convertLegacyMessages } from "../../scripts/migrate-legacy-sessions.mjs";

describe("convertLegacyMessages", () => {
  const legacy = {
    agent: {
      memory: {
        content: [
          [
            {
              id: "msg_1",
              role: "user",
              content: [{ type: "text", text: "你好" }],
              timestamp: "2026-04-16 01:05:31.072",
            },
            {
              id: "msg_2",
              role: "assistant",
              content: [
                { type: "text", text: "你好！" },
                { type: "text", text: "我是 Friday。" },
              ],
              timestamp: "2026-04-16 01:05:32.500",
            },
          ],
          [],
        ],
      },
    },
  };

  it("emits a session header followed by one message event per legacy message", () => {
    const result = convertLegacyMessages(legacy, {
      sessionId: "00000000-0000-0000-0000-000000000001",
      cwd: "/work",
      legacyTsMs: 1776272717578,
      randomHex: (() => {
        let i = 0;
        return () => String(++i).padStart(8, "0");
      })(),
    });

    expect(result.events).toHaveLength(3);
    expect(result.events[0]).toMatchObject({
      type: "session",
      version: 3,
      id: "00000000-0000-0000-0000-000000000001",
      cwd: "/work",
    });
    expect(result.events[1]).toMatchObject({
      type: "message",
      id: "00000001",
      parentId: "00000000-0000-0000-0000-000000000001",
      message: {
        role: "user",
        content: [{ type: "text", text: "你好" }],
      },
    });
    expect(result.events[2]).toMatchObject({
      type: "message",
      id: "00000002",
      parentId: "00000001",
      message: {
        role: "assistant",
        content: [{ type: "text", text: "你好!\n\n我是 Friday。".replace("!", "！") }],
      },
    });
  });

  it("reports firstMsgMs, lastMsgMs, messageCount, and a label", () => {
    const result = convertLegacyMessages(legacy, {
      sessionId: "sid",
      cwd: "/w",
      legacyTsMs: 1776272717578,
      randomHex: () => "deadbeef",
    });
    expect(result.messageCount).toBe(2);
    expect(result.firstMsgMs).toBe(Date.UTC(2026, 3, 16, 1, 5, 31, 72));
    expect(result.lastMsgMs).toBe(Date.UTC(2026, 3, 16, 1, 5, 32, 500));
    expect(result.label).toBe("你好");
  });

  it("falls back to legacy filename ts when timestamps are unparseable", () => {
    const broken = {
      agent: {
        memory: {
          content: [
            [
              { id: "x", role: "user", content: [{ type: "text", text: "hi" }], timestamp: "not-a-date" },
            ],
            [],
          ],
        },
      },
    };
    const result = convertLegacyMessages(broken, {
      sessionId: "sid",
      cwd: "/w",
      legacyTsMs: 1776272717578,
      randomHex: () => "deadbeef",
    });
    expect(result.firstMsgMs).toBe(1776272717578);
    expect(result.lastMsgMs).toBe(1776272717578);
  });

  it("throws when content[0] is missing or empty", () => {
    expect(() =>
      convertLegacyMessages(
        { agent: { memory: { content: [[], []] } } },
        { sessionId: "sid", cwd: "/w", legacyTsMs: 0, randomHex: () => "deadbeef" },
      ),
    ).toThrow(/no messages/i);
  });

  it("skips messages with unknown roles but keeps parentId chain monotonic", () => {
    const mixed = {
      agent: {
        memory: {
          content: [
            [
              { id: "a", role: "user", content: [{ type: "text", text: "ok" }], timestamp: "2026-04-16 01:05:31.072" },
              { id: "b", role: "tool", content: [{ type: "text", text: "skip" }], timestamp: "2026-04-16 01:05:32.072" },
              { id: "c", role: "assistant", content: [{ type: "text", text: "reply" }], timestamp: "2026-04-16 01:05:33.072" },
            ],
            [],
          ],
        },
      },
    };
    const result = convertLegacyMessages(mixed, {
      sessionId: "sid",
      cwd: "/w",
      legacyTsMs: 0,
      randomHex: (() => {
        let i = 0;
        return () => String(++i).padStart(8, "0");
      })(),
    });
    expect(result.events).toHaveLength(3); // session + 2 messages
    expect(result.skipped).toBe(1);
    expect(result.events[2].parentId).toBe(result.events[1].id);
  });
});
```

- [ ] **Step 3: Run the test and verify it fails**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts`

Expected: FAIL with "not implemented" for every test.

- [ ] **Step 4: Implement `convertLegacyMessages`**

Replace the stub in `scripts/migrate-legacy-sessions.mjs`:

```javascript
const ALLOWED_ROLES = new Set(["user", "assistant"]);

export function convertLegacyMessages(legacyJson, opts) {
  const { sessionId, cwd, legacyTsMs, randomHex } = opts;
  const messages = legacyJson?.agent?.memory?.content?.[0];
  if (!Array.isArray(messages) || messages.length === 0) {
    throw new Error("legacy session has no messages in content[0]");
  }

  const events = [];
  let skipped = 0;
  let firstMsgMs = Number.POSITIVE_INFINITY;
  let lastMsgMs = Number.NEGATIVE_INFINITY;
  let label = "";

  const headerTs = parseLegacyTimestamp(messages[0]?.timestamp, legacyTsMs);
  events.push({
    type: "session",
    version: 3,
    id: sessionId,
    timestamp: new Date(headerTs).toISOString(),
    cwd,
  });

  let prevId = sessionId;
  for (let i = 0; i < messages.length; i++) {
    const m = messages[i];
    if (!ALLOWED_ROLES.has(m?.role)) {
      skipped++;
      continue;
    }
    const text = Array.isArray(m?.content)
      ? m.content
          .filter((b) => b && b.type === "text" && typeof b.text === "string")
          .map((b) => b.text)
          .join("\n\n")
      : "";
    const tsMs = parseLegacyTimestamp(m?.timestamp, legacyTsMs + i);
    const id = randomHex();
    events.push({
      type: "message",
      id,
      parentId: prevId,
      timestamp: new Date(tsMs).toISOString(),
      message: {
        role: m.role,
        content: [{ type: "text", text }],
        timestamp: tsMs,
      },
    });
    prevId = id;
    if (tsMs < firstMsgMs) firstMsgMs = tsMs;
    if (tsMs > lastMsgMs) lastMsgMs = tsMs;
    if (!label && m.role === "user" && text) {
      label = text.slice(0, 40);
    }
  }

  if (firstMsgMs === Number.POSITIVE_INFINITY) {
    firstMsgMs = legacyTsMs;
    lastMsgMs = legacyTsMs;
  }

  return {
    events,
    messageCount: events.length - 1,
    firstMsgMs,
    lastMsgMs,
    label: label || "(legacy session)",
    skipped,
  };
}
```

- [ ] **Step 5: Run the tests and verify pass**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts`

Expected: all `convertLegacyMessages` tests PASS; `parseLegacyTimestamp` etc. still error on stub.

- [ ] **Step 6: Commit**

Run:
```bash
scripts/committer "wip: scaffold legacy sealseek session importer + first conversion test" scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
```

---

## Task 2: TDD `parseLegacyTimestamp`

**Files:**
- Modify: `scripts/migrate-legacy-sessions.mjs`
- Modify: `test/scripts/migrate-legacy-sessions.test.ts`

- [ ] **Step 1: Append failing tests**

Append to `test/scripts/migrate-legacy-sessions.test.ts`:

```typescript
import { parseLegacyTimestamp } from "../../scripts/migrate-legacy-sessions.mjs";

describe("parseLegacyTimestamp", () => {
  it("parses 'YYYY-MM-DD HH:MM:SS.sss' as UTC", () => {
    expect(parseLegacyTimestamp("2026-04-16 01:05:31.072", 0)).toBe(
      Date.UTC(2026, 3, 16, 1, 5, 31, 72),
    );
  });

  it("parses without milliseconds", () => {
    expect(parseLegacyTimestamp("2026-04-16 01:05:31", 0)).toBe(
      Date.UTC(2026, 3, 16, 1, 5, 31, 0),
    );
  });

  it("falls back to fallbackMs on garbage input", () => {
    expect(parseLegacyTimestamp("not-a-date", 1776272717578)).toBe(1776272717578);
    expect(parseLegacyTimestamp(undefined, 42)).toBe(42);
    expect(parseLegacyTimestamp(null, 42)).toBe(42);
  });

  it("falls back on numeric Date that ends up NaN", () => {
    expect(parseLegacyTimestamp("2026-13-99 99:99:99.999", 99)).toBe(99);
  });
});
```

- [ ] **Step 2: Run, expect fail**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts -t parseLegacyTimestamp`
Expected: FAIL with "not implemented".

- [ ] **Step 3: Implement**

Replace stub in `scripts/migrate-legacy-sessions.mjs`:

```javascript
const LEGACY_TS_RE = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/;

export function parseLegacyTimestamp(value, fallbackMs) {
  if (typeof value !== "string") return fallbackMs;
  const m = LEGACY_TS_RE.exec(value.trim());
  if (!m) return fallbackMs;
  const [, y, mo, d, h, mi, s, ms] = m;
  const ts = Date.UTC(
    Number(y),
    Number(mo) - 1,
    Number(d),
    Number(h),
    Number(mi),
    Number(s),
    ms ? Number(ms.padEnd(3, "0")) : 0,
  );
  if (!Number.isFinite(ts)) return fallbackMs;
  // Reject impossible dates (e.g. month 13) by round-tripping
  const back = new Date(ts);
  if (back.getUTCMonth() !== Number(mo) - 1 || back.getUTCDate() !== Number(d)) {
    return fallbackMs;
  }
  return ts;
}
```

- [ ] **Step 4: Run, expect pass**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts`
Expected: all current tests PASS.

- [ ] **Step 5: Commit**

```bash
scripts/committer "wip: parse legacy YYYY-MM-DD HH:MM:SS.sss timestamps as UTC with fallback" scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
```

---

## Task 3: TDD `buildStoreEntry`

**Files:**
- Modify: `scripts/migrate-legacy-sessions.mjs`
- Modify: `test/scripts/migrate-legacy-sessions.test.ts`

- [ ] **Step 1: Append failing tests**

```typescript
import { buildStoreEntry } from "../../scripts/migrate-legacy-sessions.mjs";

describe("buildStoreEntry", () => {
  it("produces a sessions.json entry matching the spec shape", () => {
    const entry = buildStoreEntry({
      sessionId: "sid-1",
      sessionFile: "/abs/sid-1.jsonl",
      firstMsgMs: 1_000,
      lastMsgMs: 2_000,
      label: "你好",
    });
    expect(entry).toMatchObject({
      sessionId: "sid-1",
      sessionFile: "/abs/sid-1.jsonl",
      sessionStartedAt: 1_000,
      lastInteractionAt: 2_000,
      updatedAt: 2_000,
      chatType: "direct",
      systemSent: true,
      abortedLastRun: false,
      origin: { provider: "legacy-import", surface: "legacy", chatType: "direct" },
      deliveryContext: { channel: "webchat" },
      lastChannel: "webchat",
      label: "你好",
    });
  });
});
```

- [ ] **Step 2: Run, expect fail**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts -t buildStoreEntry`

- [ ] **Step 3: Implement**

```javascript
export function buildStoreEntry({ sessionId, sessionFile, firstMsgMs, lastMsgMs, label }) {
  return {
    sessionId,
    sessionFile,
    sessionStartedAt: firstMsgMs,
    lastInteractionAt: lastMsgMs,
    updatedAt: lastMsgMs,
    chatType: "direct",
    systemSent: true,
    abortedLastRun: false,
    origin: { provider: "legacy-import", surface: "legacy", chatType: "direct" },
    deliveryContext: { channel: "webchat" },
    lastChannel: "webchat",
    label,
  };
}
```

- [ ] **Step 4: Run, expect pass**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts`

- [ ] **Step 5: Commit**

```bash
scripts/committer "wip: build sessions.json entry shape for legacy imports" scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
```

---

## Task 4: TDD `mergeSessionsStore` + manifest helpers

**Files:**
- Modify: `scripts/migrate-legacy-sessions.mjs`
- Modify: `test/scripts/migrate-legacy-sessions.test.ts`

- [ ] **Step 1: Append failing tests**

```typescript
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
  mergeSessionsStore,
  loadManifest,
  recordImport,
} from "../../scripts/migrate-legacy-sessions.mjs";

function tmp(prefix: string): string {
  return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}

describe("mergeSessionsStore", () => {
  it("creates sessions.json when absent", () => {
    const dir = tmp("legacy-merge-a-");
    const storePath = path.join(dir, "sessions.json");
    mergeSessionsStore({
      storePath,
      additions: { "agent:main:legacy-1": { sessionId: "s1" } },
    });
    const parsed = JSON.parse(fs.readFileSync(storePath, "utf8"));
    expect(parsed).toEqual({ "agent:main:legacy-1": { sessionId: "s1" } });
  });

  it("preserves existing keys and adds new ones atomically", () => {
    const dir = tmp("legacy-merge-b-");
    const storePath = path.join(dir, "sessions.json");
    fs.writeFileSync(storePath, JSON.stringify({ "agent:main:main": { sessionId: "live" } }));
    mergeSessionsStore({
      storePath,
      additions: { "agent:main:legacy-1": { sessionId: "s1" } },
    });
    const parsed = JSON.parse(fs.readFileSync(storePath, "utf8"));
    expect(parsed).toEqual({
      "agent:main:main": { sessionId: "live" },
      "agent:main:legacy-1": { sessionId: "s1" },
    });
    expect(fs.existsSync(storePath + ".tmp")).toBe(false);
  });

  it("does not overwrite when the same key is already present unless force=true", () => {
    const dir = tmp("legacy-merge-c-");
    const storePath = path.join(dir, "sessions.json");
    fs.writeFileSync(
      storePath,
      JSON.stringify({ "agent:main:legacy-1": { sessionId: "old" } }),
    );
    mergeSessionsStore({
      storePath,
      additions: { "agent:main:legacy-1": { sessionId: "new" } },
    });
    let parsed = JSON.parse(fs.readFileSync(storePath, "utf8"));
    expect(parsed["agent:main:legacy-1"].sessionId).toBe("old");

    mergeSessionsStore({
      storePath,
      additions: { "agent:main:legacy-1": { sessionId: "new" } },
      force: true,
    });
    parsed = JSON.parse(fs.readFileSync(storePath, "utf8"));
    expect(parsed["agent:main:legacy-1"].sessionId).toBe("new");
  });
});

describe("manifest helpers", () => {
  it("loadManifest returns empty when file missing", () => {
    const dir = tmp("legacy-mani-a-");
    expect(loadManifest(path.join(dir, "missing.json"))).toEqual({ version: 1, imported: {} });
  });

  it("recordImport writes atomically and round-trips through loadManifest", () => {
    const dir = tmp("legacy-mani-b-");
    const p = path.join(dir, ".legacy-import-manifest.json");
    recordImport({
      manifestPath: p,
      sourcePath: "/abs/default_1.json",
      record: { newSessionId: "sid", sessionKey: "legacy-1", importedAt: 10, sourceMtime: 1, messageCount: 2 },
    });
    const loaded = loadManifest(p);
    expect(loaded.imported["/abs/default_1.json"].newSessionId).toBe("sid");
    expect(fs.existsSync(p + ".tmp")).toBe(false);
  });
});
```

- [ ] **Step 2: Run, expect fail**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts -t mergeSessionsStore`

- [ ] **Step 3: Implement**

Replace stubs in `scripts/migrate-legacy-sessions.mjs`:

```javascript
function atomicWriteJson(filePath, value) {
  const tmpPath = `${filePath}.tmp`;
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(tmpPath, JSON.stringify(value, null, 2));
  fs.renameSync(tmpPath, filePath);
}

function readJsonIfExists(filePath) {
  if (!fs.existsSync(filePath)) return undefined;
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
}

export function mergeSessionsStore({ storePath, additions, force = false }) {
  const existing = readJsonIfExists(storePath) ?? {};
  const next = { ...existing };
  let added = 0;
  let skipped = 0;
  for (const [k, v] of Object.entries(additions)) {
    if (!force && Object.prototype.hasOwnProperty.call(next, k)) {
      skipped++;
      continue;
    }
    next[k] = v;
    added++;
  }
  atomicWriteJson(storePath, next);
  return { added, skipped };
}

export function loadManifest(manifestPath) {
  const v = readJsonIfExists(manifestPath);
  if (!v || typeof v !== "object" || v.version !== 1 || typeof v.imported !== "object") {
    return { version: 1, imported: {} };
  }
  return v;
}

export function recordImport({ manifestPath, sourcePath, record }) {
  const current = loadManifest(manifestPath);
  current.imported[sourcePath] = record;
  atomicWriteJson(manifestPath, current);
}
```

- [ ] **Step 4: Run, expect pass**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts`
Expected: all tests PASS.

- [ ] **Step 5: Commit**

```bash
scripts/committer "wip: atomic merge sessions.json + manifest helpers for legacy import" scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
```

---

## Task 5: Implement `runCli` (orchestration shell)

`runCli` is the only non-pure piece. It coordinates: argv parsing, source scan, per-file convert+write, store merge, manifest update, summary print. We test it end-to-end against a temp source directory to keep it from drifting.

**Files:**
- Modify: `scripts/migrate-legacy-sessions.mjs`
- Modify: `test/scripts/migrate-legacy-sessions.test.ts`

- [ ] **Step 1: Append failing integration test**

```typescript
import { runCli } from "../../scripts/migrate-legacy-sessions.mjs";

describe("runCli end-to-end", () => {
  function setup() {
    const root = tmp("legacy-cli-");
    const source = path.join(root, "src-sessions");
    const stateDir = path.join(root, ".sealseek");
    fs.mkdirSync(source, { recursive: true });
    const legacy = {
      agent: {
        memory: {
          content: [
            [
              {
                id: "msg_1",
                role: "user",
                content: [{ type: "text", text: "hello" }],
                timestamp: "2026-04-16 01:05:31.072",
              },
              {
                id: "msg_2",
                role: "assistant",
                content: [{ type: "text", text: "world" }],
                timestamp: "2026-04-16 01:05:32.072",
              },
            ],
            [],
          ],
        },
      },
    };
    fs.writeFileSync(
      path.join(source, "default_1776272717578.json"),
      JSON.stringify(legacy),
    );
    return { root, source, stateDir };
  }

  it("imports a legacy file into the new layout and updates sessions.json + manifest", async () => {
    const { source, stateDir } = setup();
    const code = await runCli({
      argv: ["--source", source, "--agent", "main", "--state-dir", stateDir],
      env: {},
    });
    expect(code).toBe(0);

    const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
    const jsonl = fs
      .readdirSync(sessionsDir)
      .filter((f) => f.endsWith(".jsonl") && !f.includes("trajectory"));
    expect(jsonl).toHaveLength(1);

    const lines = fs.readFileSync(path.join(sessionsDir, jsonl[0]), "utf8").trim().split("\n");
    expect(lines).toHaveLength(3); // session + 2 messages
    const parsed = lines.map((l) => JSON.parse(l));
    expect(parsed[0].type).toBe("session");
    expect(parsed[1].parentId).toBe(parsed[0].id);
    expect(parsed[2].parentId).toBe(parsed[1].id);

    const store = JSON.parse(fs.readFileSync(path.join(sessionsDir, "sessions.json"), "utf8"));
    expect(Object.keys(store)).toContain("agent:main:legacy-1776272717578");

    const manifest = JSON.parse(
      fs.readFileSync(path.join(sessionsDir, ".legacy-import-manifest.json"), "utf8"),
    );
    expect(Object.keys(manifest.imported)).toHaveLength(1);
  });

  it("is idempotent: a second run imports nothing", async () => {
    const { source, stateDir } = setup();
    await runCli({ argv: ["--source", source, "--state-dir", stateDir], env: {} });
    const before = fs.readdirSync(path.join(stateDir, "agents", "main", "sessions")).sort();
    const code = await runCli({ argv: ["--source", source, "--state-dir", stateDir], env: {} });
    const after = fs.readdirSync(path.join(stateDir, "agents", "main", "sessions")).sort();
    expect(code).toBe(0);
    expect(after).toEqual(before);
  });

  it("--dry-run writes nothing", async () => {
    const { source, stateDir } = setup();
    const code = await runCli({
      argv: ["--source", source, "--state-dir", stateDir, "--dry-run"],
      env: {},
    });
    expect(code).toBe(0);
    expect(fs.existsSync(path.join(stateDir, "agents"))).toBe(false);
  });
});
```

- [ ] **Step 2: Run, expect fail**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts -t runCli`

- [ ] **Step 3: Implement `runCli`**

Replace stub in `scripts/migrate-legacy-sessions.mjs`:

```javascript
function parseArgs(argv) {
  const out = { source: null, agent: "main", stateDir: null, dryRun: false, force: false, verbose: false };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    switch (a) {
      case "--source": out.source = argv[++i]; break;
      case "--agent": out.agent = argv[++i]; break;
      case "--state-dir": out.stateDir = argv[++i]; break;
      case "--dry-run": out.dryRun = true; break;
      case "--force": out.force = true; break;
      case "--verbose": out.verbose = true; break;
      default: throw new Error(`unknown arg: ${a}`);
    }
  }
  return out;
}

function resolveStateDir(env, override) {
  if (override) return override;
  const explicit = env.OPENCLAW_STATE_DIR;
  if (explicit) return explicit;
  const home = env.HOME || env.USERPROFILE || os.homedir();
  return path.join(home, ".sealseek");
}

export async function runCli({ argv, env }) {
  const args = parseArgs(argv);
  const stateDir = resolveStateDir(env, args.stateDir);
  const sourceDir = args.source ?? path.join(stateDir.replace(/\.sealseek$/, ".sealseek"), "workspaces", "default", "sessions");
  const sessionsDir = path.join(stateDir, "agents", args.agent, "sessions");
  const storePath = path.join(sessionsDir, "sessions.json");
  const manifestPath = path.join(sessionsDir, ".legacy-import-manifest.json");

  let entries;
  try {
    entries = await fsp.readdir(sourceDir, { withFileTypes: true });
  } catch (err) {
    console.error(`cannot read source ${sourceDir}: ${err.message}`);
    return 1;
  }

  const manifest = loadManifest(manifestPath);
  const additions = {};
  const newManifestRecords = [];
  let imported = 0, skipped = 0, failed = 0;

  for (const entry of entries) {
    if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
    const abs = path.join(sourceDir, entry.name);
    const stat = await fsp.stat(abs);
    const existing = manifest.imported[abs];
    if (!args.force && existing && existing.sourceMtime === stat.mtimeMs) {
      if (args.verbose) console.log(`skip ${entry.name} (already imported)`);
      skipped++;
      continue;
    }

    let legacy;
    try {
      legacy = JSON.parse(await fsp.readFile(abs, "utf8"));
    } catch (err) {
      console.error(`fail ${entry.name}: invalid JSON (${err.message})`);
      failed++;
      continue;
    }

    const m = /^default_(\d+)\.json$/.exec(entry.name);
    const legacyTsMs = m ? Number(m[1]) : stat.mtimeMs;
    const sessionKey = `legacy-${m ? m[1] : String(legacyTsMs)}`;
    const sessionId = crypto.randomUUID();
    const sessionFile = path.join(sessionsDir, `${sessionId}.jsonl`);

    let result;
    try {
      result = convertLegacyMessages(legacy, {
        sessionId,
        cwd: path.join(stateDir, "workspace"),
        legacyTsMs,
        randomHex: () => crypto.randomBytes(4).toString("hex"),
      });
    } catch (err) {
      console.error(`fail ${entry.name}: ${err.message}`);
      failed++;
      continue;
    }

    if (!args.dryRun) {
      fs.mkdirSync(sessionsDir, { recursive: true });
      const tmpPath = `${sessionFile}.tmp`;
      fs.writeFileSync(tmpPath, result.events.map((e) => JSON.stringify(e)).join("\n") + "\n");
      fs.renameSync(tmpPath, sessionFile);
    }

    additions[`agent:${args.agent}:${sessionKey}`] = buildStoreEntry({
      sessionId,
      sessionFile,
      firstMsgMs: result.firstMsgMs,
      lastMsgMs: result.lastMsgMs,
      label: result.label,
    });
    newManifestRecords.push({
      sourcePath: abs,
      record: {
        newSessionId: sessionId,
        sessionKey,
        importedAt: Date.now(),
        sourceMtime: stat.mtimeMs,
        messageCount: result.messageCount,
      },
    });
    imported++;
    if (args.verbose) console.log(`import ${entry.name} -> ${sessionId} (${result.messageCount} msgs)`);
  }

  if (!args.dryRun && imported > 0) {
    mergeSessionsStore({ storePath, additions, force: args.force });
    for (const r of newManifestRecords) {
      recordImport({ manifestPath, sourcePath: r.sourcePath, record: r.record });
    }
  }

  console.log(
    `imported=${imported} skipped=${skipped} failed=${failed} dry=${args.dryRun ? "true" : "false"}`,
  );
  return 0;
}
```

- [ ] **Step 4: Run, expect pass**

Run: `pnpm test test/scripts/migrate-legacy-sessions.test.ts`
Expected: all tests PASS.

- [ ] **Step 5: Format + lint the new files**

Run:
```bash
pnpm exec oxfmt --write --threads=1 scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
```

Then run the file-scoped lint (the repo wrapper picks up the right ruleset):
```bash
node scripts/run-oxlint.mjs scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
```
Expected: no errors. Fix any reported lint issues inline; rerun until clean.

- [ ] **Step 6: Commit**

```bash
scripts/committer "feat(scripts): import legacy sealseek sessions into sealclaw layout" scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
```

---

## Task 6: Verify against real legacy data

This is a manual verification step — no new code unless an issue surfaces.

**Files:** none modified unless a bug is found.

- [ ] **Step 1: Dry-run against the real source directory**

Run:
```bash
node scripts/migrate-legacy-sessions.mjs --source ~/.sealseek/workspaces/default/sessions --state-dir ~/.sealseek --dry-run --verbose
```
Expected: prints `import <file> -> <uuid> (N msgs)` for each `default_*.json`, ends with `imported=X skipped=0 failed=0 dry=true`. No files created under `~/.sealseek/agents/main/sessions/`.

- [ ] **Step 2: Verify no failures**

If `failed>0`, inspect the per-file error lines. Common cases:
- Source file has `content[0]` empty → confirm by `jq '.agent.memory.content[0] | length' <file>`; if truly empty, acceptable to skip — these were aborted sessions.
- Invalid JSON → confirm by `jq . <file>`; if it's a partial write artifact, leave it.

Do not modify the script unless the failure category is unexpected.

- [ ] **Step 3: Snapshot the current state before real import**

Run:
```bash
cp ~/.sealseek/agents/main/sessions/sessions.json ~/.sealseek/agents/main/sessions/sessions.json.pre-legacy-import.bak
```
So a rollback is one `cp` away.

- [ ] **Step 4: Real run**

Run:
```bash
node scripts/migrate-legacy-sessions.mjs --source ~/.sealseek/workspaces/default/sessions --state-dir ~/.sealseek --verbose
```
Expected: `imported=N skipped=0 failed=K` with the same N as the dry-run.

- [ ] **Step 5: Sanity-check `sessions.json` is still valid**

Run:
```bash
node -e "console.log(Object.keys(JSON.parse(require('fs').readFileSync(process.env.HOME + '/.sealseek/agents/main/sessions/sessions.json'))).length)"
```
Expected: prints `1 + N` (one preexisting `agent:main:main` plus N legacy keys).

- [ ] **Step 6: Re-run to confirm idempotency**

Run:
```bash
node scripts/migrate-legacy-sessions.mjs --source ~/.sealseek/workspaces/default/sessions --state-dir ~/.sealseek
```
Expected: `imported=0 skipped=N failed=0`.

- [ ] **Step 7: UI smoke check**

Launch the sealclaw desktop app (or whichever UI surface the user uses). In the session list:
- Confirm legacy rows appear with the first-user-message label as title.
- Sort by updatedAt — legacy rows interleave correctly with live sessions.
- Open one legacy session; confirm all messages render in source order with correct role colors.
- Tail `~/.sealseek/logs/*` (or `./scripts/clawlog.sh` if available) for the lifetime of opening one legacy session; confirm no transcript parse errors.

If anything fails the UI check, capture the error message, write a failing test for the underlying helper, fix, and re-run from Task 5 Step 6 onward.

- [ ] **Step 8: Final commit (only if verification surfaced fixes)**

If no fixes were needed, skip. Otherwise:
```bash
scripts/committer "fix(scripts): handle <specific issue> in legacy session importer" scripts/migrate-legacy-sessions.mjs test/scripts/migrate-legacy-sessions.test.ts
```

---

## Self-Review Notes

- **Spec coverage:** Each spec requirement maps to a task — schema mapping (Task 1), timestamp parsing (Task 2), store entry shape (Task 3), atomic merge + manifest idempotency (Task 4), CLI orchestration + dry-run + force (Task 5), real-data verification (Task 6). The spec's "label = first user message, 40 chars" is implemented in `convertLegacyMessages`.
- **Placeholder check:** every step shows the code or the exact command.
- **Type consistency:** `convertLegacyMessages` returns `{events, messageCount, firstMsgMs, lastMsgMs, label, skipped}` — those exact names are consumed by `runCli` in Task 5 and by `buildStoreEntry` in Task 3. `mergeSessionsStore` takes `{storePath, additions, force}` consistently.
- **Out-of-scope kept out:** no model binding, no continuation, no compaction, no trajectory files. Matches spec's "non-goals" section.
