# Gateway Auto-Migration of Legacy sealseek Workspaces — 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:** Add a one-shot startup hook to the sealclaw gateway that detects `<stateDir>/workspaces/` (legacy sealseek tree) and runs both migration scripts exactly once, recording the outcome in a sentinel JSON.

**Architecture:** New module `src/gateway/server-startup-legacy-workspaces-migration.ts` exports `runStartupLegacyWorkspacesMigration({ env, log, deps? })`. Called sequentially **before** the existing `Promise.all` in `prepareGatewayPluginBootstrap` so it cannot race with `migrateOrphanedSessionKeys` on `sessions.json`. Two gates: sentinel absent AND `workspaces/` present. Sentinel written once after the first attempt (success or failure), never retried.

**Tech Stack:** TypeScript ESM (compiled via tsgo), Vitest, Node `node:fs` / `node:path`, in-process dynamic import of the `.mjs` scripts in `scripts/`.

**Spec:** [docs/superpowers/specs/2026-05-26-gateway-legacy-workspaces-auto-migration-design.md](docs/superpowers/specs/2026-05-26-gateway-legacy-workspaces-auto-migration-design.md)

---

## File Structure

| Path | Role |
|------|------|
| `src/gateway/server-startup-legacy-workspaces-migration.ts` | New module. Exports `runStartupLegacyWorkspacesMigration`, types, sentinel filename const. |
| `src/gateway/server-startup-legacy-workspaces-migration.test.ts` | Colocated unit tests. Uses `vi.fn()` injection — no real fs at the script level. |
| `src/gateway/server-startup-plugins.ts:53-74` | Modify. Call new migration sequentially before the existing `Promise.all`. |
| `src/gateway/server-startup-plugins.test.ts` | Modify. Add one integration assertion: `minimalTestGateway=true` does not invoke new migration. |
| `package.json` | Modify. Append the two `.mjs` scripts to the `files` array so npm-installed builds can dynamic-import them. |
| `scripts/migrate-legacy-sessions.mjs` | Unchanged. Already exports `runCli({ argv, env })`. |
| `scripts/migrate-legacy-skills.mjs` | Unchanged. Already exports `runCli({ argv, env })`. |

---

## Conventions

- **Test file location:** colocated `*.test.ts` next to the source (see [src/gateway/server-startup-session-migration.test.ts](src/gateway/server-startup-session-migration.test.ts)).
- **Logger param shape:** `{ info, warn, error, debug }` — all `(msg: string) => void`. Real logger from `createSubsystemLogger("gateway/legacy-workspaces-migration")` ([src/logging/subsystem.ts:20-31](src/logging/subsystem.ts#L20-L31)).
- **State-dir resolver:** import `resolveStateDir` from [src/config/paths.ts](src/config/paths.ts) — never reimplement.
- **Atomic JSON write:** the pattern is tmp + rename. Each call uses `fs.writeFileSync(tmpPath, ...)` then `fs.renameSync(tmpPath, finalPath)`. `fs.mkdirSync(dirname(finalPath), { recursive: true })` first.
- **Commits:** use `scripts/committer "<msg>" <file...>`. Format is informal but conventional-ish ("feat:", "test:", "chore:" prefixes are common in `git log` but not required).
- **Test runs:** `pnpm test <test-file-path>` for targeted, `pnpm test:changed` before push.

---

## Task 1: Bootstrap module + Gate 1 (sentinel exists → skip)

**Files:**
- Create: `src/gateway/server-startup-legacy-workspaces-migration.ts`
- Create: `src/gateway/server-startup-legacy-workspaces-migration.test.ts`

- [ ] **Step 1: Write the failing test**

Create `src/gateway/server-startup-legacy-workspaces-migration.test.ts`:

```ts
import * as path from "node:path";
import * as fs from "node:fs";
import * as os from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
  LEGACY_WORKSPACES_MIGRATION_SENTINEL_FILENAME,
  runStartupLegacyWorkspacesMigration,
} from "./server-startup-legacy-workspaces-migration.js";

function makeLog() {
  return {
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
    debug: vi.fn(),
  };
}

function mkTmp(): string {
  return fs.mkdtempSync(path.join(os.tmpdir(), "sealclaw-legacy-migration-test-"));
}

describe("runStartupLegacyWorkspacesMigration", () => {
  let tmpStateDir: string;

  beforeEach(() => {
    tmpStateDir = mkTmp();
  });

  afterEach(() => {
    fs.rmSync(tmpStateDir, { recursive: true, force: true });
  });

  it("short-circuits when sentinel already exists", async () => {
    const sentinelPath = path.join(tmpStateDir, LEGACY_WORKSPACES_MIGRATION_SENTINEL_FILENAME);
    fs.writeFileSync(sentinelPath, "{}");
    const runSessionsCli = vi.fn();
    const runSkillsCli = vi.fn();
    const log = makeLog();

    await runStartupLegacyWorkspacesMigration({
      env: {},
      log,
      deps: {
        resolveStateDir: () => tmpStateDir,
        runSessionsCli,
        runSkillsCli,
      },
    });

    expect(runSessionsCli).not.toHaveBeenCalled();
    expect(runSkillsCli).not.toHaveBeenCalled();
    expect(log.debug).toHaveBeenCalledOnce();
    expect(log.debug.mock.calls[0][0]).toMatch(/already migrated|sentinel/i);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm test src/gateway/server-startup-legacy-workspaces-migration.test.ts`
Expected: FAIL with `Cannot find module './server-startup-legacy-workspaces-migration.js'`.

- [ ] **Step 3: Create the module with sentinel-exists short-circuit**

Create `src/gateway/server-startup-legacy-workspaces-migration.ts`:

```ts
import * as fs from "node:fs";
import * as path from "node:path";
import { resolveStateDir as defaultResolveStateDir } from "../config/paths.js";

export const LEGACY_WORKSPACES_MIGRATION_SENTINEL_FILENAME =
  ".legacy-workspaces-migrated.json";

export type ScriptOutcome =
  | { status: "success"; exitCode: 0 }
  | { status: "failed"; exitCode?: number; error: string }
  | { status: "nothing-to-do" };

export type LegacyWorkspacesMigrationSentinel = {
  version: 1;
  completedAt: number;
  workspacesDir: string;
  sessions: ScriptOutcome;
  skills: ScriptOutcome;
};

type Logger = {
  info: (msg: string) => void;
  warn: (msg: string) => void;
  error: (msg: string) => void;
  debug: (msg: string) => void;
};

type RunCli = (args: { argv: string[]; env: NodeJS.ProcessEnv }) => Promise<number>;

export type LegacyWorkspacesMigrationDeps = {
  runSessionsCli?: RunCli;
  runSkillsCli?: RunCli;
  resolveStateDir?: (env: NodeJS.ProcessEnv) => string;
  now?: () => number;
};

export async function runStartupLegacyWorkspacesMigration(params: {
  env?: NodeJS.ProcessEnv;
  log: Logger;
  deps?: LegacyWorkspacesMigrationDeps;
}): Promise<void> {
  const env = params.env ?? process.env;
  const resolveStateDir = params.deps?.resolveStateDir ?? defaultResolveStateDir;
  const stateDir = resolveStateDir(env);
  const sentinelPath = path.join(stateDir, LEGACY_WORKSPACES_MIGRATION_SENTINEL_FILENAME);

  if (fs.existsSync(sentinelPath)) {
    params.log.debug(
      `gateway: legacy workspaces migration sentinel present at ${sentinelPath}; skipping`,
    );
    return;
  }

  // Gates beyond sentinel-check arrive in later tasks.
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pnpm test src/gateway/server-startup-legacy-workspaces-migration.test.ts`
Expected: PASS (1 test).

- [ ] **Step 5: Commit**

```bash
scripts/committer "feat(gateway): scaffold legacy workspaces migration with sentinel short-circuit" \
  src/gateway/server-startup-legacy-workspaces-migration.ts \
  src/gateway/server-startup-legacy-workspaces-migration.test.ts
```

---

## Task 2: Gate 2 — `workspaces/` absent → write nothing-to-do sentinel

**Files:**
- Modify: `src/gateway/server-startup-legacy-workspaces-migration.ts`
- Modify: `src/gateway/server-startup-legacy-workspaces-migration.test.ts`

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

Append to `src/gateway/server-startup-legacy-workspaces-migration.test.ts` inside the existing `describe`:

```ts
  it("writes nothing-to-do sentinel when workspaces/ does not exist", async () => {
    const runSessionsCli = vi.fn();
    const runSkillsCli = vi.fn();
    const log = makeLog();

    await runStartupLegacyWorkspacesMigration({
      env: {},
      log,
      deps: {
        resolveStateDir: () => tmpStateDir,
        runSessionsCli,
        runSkillsCli,
        now: () => 1730000000000,
      },
    });

    expect(runSessionsCli).not.toHaveBeenCalled();
    expect(runSkillsCli).not.toHaveBeenCalled();

    const sentinelPath = path.join(tmpStateDir, LEGACY_WORKSPACES_MIGRATION_SENTINEL_FILENAME);
    expect(fs.existsSync(sentinelPath)).toBe(true);
    const sentinel = JSON.parse(fs.readFileSync(sentinelPath, "utf8"));
    expect(sentinel).toEqual({
      version: 1,
      completedAt: 1730000000000,
      workspacesDir: path.join(tmpStateDir, "workspaces"),
      sessions: { status: "nothing-to-do" },
      skills: { status: "nothing-to-do" },
    });
    expect(log.debug).toHaveBeenCalled();
  });
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm test src/gateway/server-startup-legacy-workspaces-migration.test.ts`
Expected: FAIL — sentinel file is not created.

- [ ] **Step 3: Add the gate + sentinel writer**

Edit `src/gateway/server-startup-legacy-workspaces-migration.ts`. Replace the trailing comment `// Gates beyond sentinel-check arrive in later tasks.` with:

```ts
  const workspacesDir = path.join(stateDir, "workspaces");
  const now = params.deps?.now ?? Date.now;

  if (!fs.existsSync(workspacesDir)) {
    writeSentinel(sentinelPath, {
      version: 1,
      completedAt: now(),
      workspacesDir,
      sessions: { status: "nothing-to-do" },
      skills: { status: "nothing-to-do" },
    });
    params.log.debug(
      `gateway: no legacy workspaces at ${workspacesDir}; recorded nothing-to-do sentinel`,
    );
    return;
  }

  // Script invocation arrives in Task 3.
}

function writeSentinel(sentinelPath: string, payload: LegacyWorkspacesMigrationSentinel): void {
  fs.mkdirSync(path.dirname(sentinelPath), { recursive: true });
  const tmpPath = `${sentinelPath}.tmp`;
  fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2));
  fs.renameSync(tmpPath, sentinelPath);
}
```

- [ ] **Step 4: Run tests to verify both pass**

Run: `pnpm test src/gateway/server-startup-legacy-workspaces-migration.test.ts`
Expected: PASS (2 tests).

- [ ] **Step 5: Commit**

```bash
scripts/committer "feat(gateway): skip and record nothing-to-do when legacy workspaces absent" \
  src/gateway/server-startup-legacy-workspaces-migration.ts \
  src/gateway/server-startup-legacy-workspaces-migration.test.ts
```

---

## Task 3: Happy path — both scripts succeed, sequentially

**Files:**
- Modify: `src/gateway/server-startup-legacy-workspaces-migration.ts`
- Modify: `src/gateway/server-startup-legacy-workspaces-migration.test.ts`

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

Append to the existing `describe`:

```ts
  it("invokes sessions then skills sequentially and records success", async () => {
    fs.mkdirSync(path.join(tmpStateDir, "workspaces"), { recursive: true });
    const callOrder: string[] = [];
    const runSessionsCli = vi.fn(async () => {
      callOrder.push("sessions");
      return 0;
    });
    const runSkillsCli = vi.fn(async () => {
      callOrder.push("skills");
      return 0;
    });
    const log = makeLog();

    await runStartupLegacyWorkspacesMigration({
      env: { OPENCLAW_STATE_DIR: tmpStateDir },
      log,
      deps: {
        resolveStateDir: () => tmpStateDir,
        runSessionsCli,
        runSkillsCli,
        now: () => 1730000000000,
      },
    });

    expect(callOrder).toEqual(["sessions", "skills"]);
    expect(runSessionsCli).toHaveBeenCalledWith({
      argv: [],
      env: { OPENCLAW_STATE_DIR: tmpStateDir },
    });
    expect(runSkillsCli).toHaveBeenCalledWith({
      argv: [],
      env: { OPENCLAW_STATE_DIR: tmpStateDir },
    });

    const sentinelPath = path.join(tmpStateDir, LEGACY_WORKSPACES_MIGRATION_SENTINEL_FILENAME);
    const sentinel = JSON.parse(fs.readFileSync(sentinelPath, "utf8"));
    expect(sentinel.sessions).toEqual({ status: "success", exitCode: 0 });
    expect(sentinel.skills).toEqual({ status: "success", exitCode: 0 });
    expect(sentinel.completedAt).toBe(1730000000000);

    expect(log.info).toHaveBeenCalledOnce();
    expect(log.info.mock.calls[0][0]).toContain("sessions=success");
    expect(log.info.mock.calls[0][0]).toContain("skills=success");
  });
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm test src/gateway/server-startup-legacy-workspaces-migration.test.ts`
Expected: FAIL — neither fake script is invoked yet (the module currently returns at the workspaces-absent branch when present too).

- [ ] **Step 3: Wire in script invocation**

Edit `src/gateway/server-startup-legacy-workspaces-migration.ts`. Add the default loader functions at the top, after the `import`s but before the constants:

```ts
async function defaultRunSessionsCli(args: {
  argv: string[];
  env: NodeJS.ProcessEnv;
}): Promise<number> {
  const mod = await import("../../scripts/migrate-legacy-sessions.mjs");
  return mod.runCli(args);
}

async function defaultRunSkillsCli(args: {
  argv: string[];
  env: NodeJS.ProcessEnv;
}): Promise<number> {
  const mod = await import("../../scripts/migrate-legacy-skills.mjs");
  return mod.runCli(args);
}
```

Replace the trailing comment `// Script invocation arrives in Task 3.` and the closing brace of the function with:

```ts
  const runSessionsCli = params.deps?.runSessionsCli ?? defaultRunSessionsCli;
  const runSkillsCli = params.deps?.runSkillsCli ?? defaultRunSkillsCli;

  const sessions = await invokeScript(runSessionsCli, env);
  const skills = await invokeScript(runSkillsCli, env);

  writeSentinel(sentinelPath, {
    version: 1,
    completedAt: now(),
    workspacesDir,
    sessions,
    skills,
  });
  params.log.info(
    `gateway: legacy workspaces migrated: sessions=${sessions.status}, skills=${skills.status}`,
  );
}

async function invokeScript(
  runCli: RunCli,
  env: NodeJS.ProcessEnv,
): Promise<ScriptOutcome> {
  try {
    const exitCode = await runCli({ argv: [], env });
    if (exitCode === 0) return { status: "success", exitCode: 0 };
    return { status: "failed", exitCode, error: `exit code ${exitCode}` };
  } catch (err) {
    return { status: "failed", error: String(err) };
  }
}
```

- [ ] **Step 4: Run tests to verify all three pass**

Run: `pnpm test src/gateway/server-startup-legacy-workspaces-migration.test.ts`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
scripts/committer "feat(gateway): invoke legacy migration scripts sequentially with sentinel summary" \
  src/gateway/server-startup-legacy-workspaces-migration.ts \
  src/gateway/server-startup-legacy-workspaces-migration.test.ts
```

---

## Task 4: Failure paths — script throws / non-zero exit

**Files:**
- Modify: `src/gateway/server-startup-legacy-workspaces-migration.test.ts`

No production-code changes — `invokeScript` already handles both paths. This task locks them in with tests.

- [ ] **Step 1: Add the failing tests**

Append three tests to the existing `describe`:

```ts
  it("records failed status when sessions script throws and still runs skills", async () => {
    fs.mkdirSync(path.join(tmpStateDir, "workspaces"), { recursive: true });
    const runSessionsCli = vi.fn(async () => {
      throw new Error("boom");
    });
    const runSkillsCli = vi.fn(async () => 0);
    const log = makeLog();

    await runStartupLegacyWorkspacesMigration({
      env: {},
      log,
      deps: {
        resolveStateDir: () => tmpStateDir,
        runSessionsCli,
        runSkillsCli,
      },
    });

    expect(runSkillsCli).toHaveBeenCalledOnce();
    const sentinel = JSON.parse(
      fs.readFileSync(
        path.join(tmpStateDir, LEGACY_WORKSPACES_MIGRATION_SENTINEL_FILENAME),
        "utf8",
      ),
    );
    expect(sentinel.sessions.status).toBe("failed");
    expect(sentinel.sessions.error).toContain("boom");
    expect(sentinel.skills.status).toBe("success");
    expect(log.info.mock.calls[0][0]).toContain("sessions=failed");
    expect(log.info.mock.calls[0][0]).toContain("skills=success");
  });

  it("records failed status with exitCode when script returns non-zero", async () => {
    fs.mkdirSync(path.join(tmpStateDir, "workspaces"), { recursive: true });
    const runSessionsCli = vi.fn(async () => 2);
    const runSkillsCli = vi.fn(async () => 0);
    const log = makeLog();

    await runStartupLegacyWorkspacesMigration({
      env: {},
      log,
      deps: {
        resolveStateDir: () => tmpStateDir,
        runSessionsCli,
        runSkillsCli,
      },
    });

    const sentinel = JSON.parse(
      fs.readFileSync(
        path.join(tmpStateDir, LEGACY_WORKSPACES_MIGRATION_SENTINEL_FILENAME),
        "utf8",
      ),
    );
    expect(sentinel.sessions).toEqual({
      status: "failed",
      exitCode: 2,
      error: "exit code 2",
    });
  });

  it("records failed status when skills script throws but sessions succeeded", async () => {
    fs.mkdirSync(path.join(tmpStateDir, "workspaces"), { recursive: true });
    const runSessionsCli = vi.fn(async () => 0);
    const runSkillsCli = vi.fn(async () => {
      throw new Error("disk full");
    });
    const log = makeLog();

    await runStartupLegacyWorkspacesMigration({
      env: {},
      log,
      deps: {
        resolveStateDir: () => tmpStateDir,
        runSessionsCli,
        runSkillsCli,
      },
    });

    const sentinel = JSON.parse(
      fs.readFileSync(
        path.join(tmpStateDir, LEGACY_WORKSPACES_MIGRATION_SENTINEL_FILENAME),
        "utf8",
      ),
    );
    expect(sentinel.sessions.status).toBe("success");
    expect(sentinel.skills.status).toBe("failed");
    expect(sentinel.skills.error).toContain("disk full");
  });
```

- [ ] **Step 2: Run tests to verify all six pass**

Run: `pnpm test src/gateway/server-startup-legacy-workspaces-migration.test.ts`
Expected: PASS (6 tests).

- [ ] **Step 3: Commit**

```bash
scripts/committer "test(gateway): cover legacy migration script throw and non-zero exit paths" \
  src/gateway/server-startup-legacy-workspaces-migration.test.ts
```

---

## Task 5: Sentinel write failure resilience

**Files:**
- Modify: `src/gateway/server-startup-legacy-workspaces-migration.ts`
- Modify: `src/gateway/server-startup-legacy-workspaces-migration.test.ts`

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

Append to the existing `describe`:

```ts
  it("logs error and does not throw when sentinel write fails", async () => {
    fs.mkdirSync(path.join(tmpStateDir, "workspaces"), { recursive: true });
    const runSessionsCli = vi.fn(async () => 0);
    const runSkillsCli = vi.fn(async () => 0);
    const log = makeLog();

    // Make the sentinel parent path a file so any mkdirSync/rename onto its directory throws.
    const sentinelPath = path.join(tmpStateDir, LEGACY_WORKSPACES_MIGRATION_SENTINEL_FILENAME);
    fs.mkdirSync(sentinelPath, { recursive: true });

    await expect(
      runStartupLegacyWorkspacesMigration({
        env: {},
        log,
        deps: {
          resolveStateDir: () => tmpStateDir,
          runSessionsCli,
          runSkillsCli,
        },
      }),
    ).resolves.toBeUndefined();

    expect(log.error).toHaveBeenCalledOnce();
    expect(log.error.mock.calls[0][0]).toMatch(/sentinel/i);
  });
```

(The test forces a write failure by creating a directory at the sentinel path; `fs.renameSync(tmpPath, sentinelPath)` rejects renaming a file over a non-empty directory.)

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm test src/gateway/server-startup-legacy-workspaces-migration.test.ts`
Expected: FAIL — currently the rename throws and propagates out of `runStartupLegacyWorkspacesMigration`.

- [ ] **Step 3: Wrap sentinel write in try/catch**

Edit `src/gateway/server-startup-legacy-workspaces-migration.ts`. Replace the two `writeSentinel(sentinelPath, ...)` call sites with `safeWriteSentinel(sentinelPath, ..., params.log)`. Add the helper:

```ts
function safeWriteSentinel(
  sentinelPath: string,
  payload: LegacyWorkspacesMigrationSentinel,
  log: Logger,
): void {
  try {
    writeSentinel(sentinelPath, payload);
  } catch (err) {
    log.error(
      `gateway: failed to write legacy workspaces migration sentinel at ${sentinelPath}: ${String(err)}`,
    );
  }
}
```

The full updated function body (call sites only) reads:

```ts
  if (!fs.existsSync(workspacesDir)) {
    safeWriteSentinel(
      sentinelPath,
      {
        version: 1,
        completedAt: now(),
        workspacesDir,
        sessions: { status: "nothing-to-do" },
        skills: { status: "nothing-to-do" },
      },
      params.log,
    );
    params.log.debug(
      `gateway: no legacy workspaces at ${workspacesDir}; recorded nothing-to-do sentinel`,
    );
    return;
  }

  const runSessionsCli = params.deps?.runSessionsCli ?? defaultRunSessionsCli;
  const runSkillsCli = params.deps?.runSkillsCli ?? defaultRunSkillsCli;

  const sessions = await invokeScript(runSessionsCli, env);
  const skills = await invokeScript(runSkillsCli, env);

  safeWriteSentinel(
    sentinelPath,
    {
      version: 1,
      completedAt: now(),
      workspacesDir,
      sessions,
      skills,
    },
    params.log,
  );
  params.log.info(
    `gateway: legacy workspaces migrated: sessions=${sessions.status}, skills=${skills.status}`,
  );
}
```

- [ ] **Step 4: Run tests to verify all seven pass**

Run: `pnpm test src/gateway/server-startup-legacy-workspaces-migration.test.ts`
Expected: PASS (7 tests).

- [ ] **Step 5: Commit**

```bash
scripts/committer "fix(gateway): swallow sentinel write failures and log error" \
  src/gateway/server-startup-legacy-workspaces-migration.ts \
  src/gateway/server-startup-legacy-workspaces-migration.test.ts
```

---

## Task 6: Wire into `server-startup-plugins.ts` + integration test

**Files:**
- Modify: `src/gateway/server-startup-plugins.ts`
- Modify: `src/gateway/server-startup-plugins.test.ts`

- [ ] **Step 1: Add the failing integration assertion**

Edit `src/gateway/server-startup-plugins.test.ts`. Near the top with the other `vi.hoisted` mocks (around line 104), add:

```ts
const runStartupLegacyWorkspacesMigration = vi.hoisted(() =>
  vi.fn(async (_params: unknown) => undefined),
);
```

Near the other `vi.mock` blocks (around line 152), add:

```ts
vi.mock("./server-startup-legacy-workspaces-migration.js", () => ({
  runStartupLegacyWorkspacesMigration: (params: unknown) =>
    runStartupLegacyWorkspacesMigration(params),
}));
```

In the `beforeEach` block (around line 180), add:

```ts
    runStartupLegacyWorkspacesMigration.mockClear();
```

Then append a new `it` inside the existing `describe("prepareGatewayPluginBootstrap startup plugins", ...)`:

```ts
  it("invokes legacy workspaces migration before Promise.all when not minimal", async () => {
    const callOrder: string[] = [];
    runStartupLegacyWorkspacesMigration.mockImplementation(async () => {
      callOrder.push("legacy");
    });
    runStartupSessionMigration.mockImplementation(async () => {
      callOrder.push("sessions");
    });
    runChannelPluginStartupMaintenance.mockImplementation(async () => {
      callOrder.push("channels");
    });

    applyPluginAutoEnable.mockReturnValue({ config: { channels: { telegram: { botToken: "t" } } } });
    loadGatewayStartupPlugins.mockResolvedValue({
      pluginRegistry: { diagnostics: [], gatewayHandlers: {}, plugins: [] },
      gatewayMethods: ["ping"],
    });

    const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js");
    await prepareGatewayPluginBootstrap({
      cfgAtStart: { channels: { telegram: { botToken: "t" } } } as unknown as Parameters<
        typeof prepareGatewayPluginBootstrap
      >[0]["cfgAtStart"],
      startupRuntimeConfig: { channels: { telegram: { botToken: "t" } } } as unknown as Parameters<
        typeof prepareGatewayPluginBootstrap
      >[0]["startupRuntimeConfig"],
      minimalTestGateway: false,
      log: createLog(),
    });

    expect(runStartupLegacyWorkspacesMigration).toHaveBeenCalledOnce();
    expect(callOrder[0]).toBe("legacy");
    expect(callOrder.slice(1).sort()).toEqual(["channels", "sessions"].sort());
  });

  it("does not invoke legacy workspaces migration when minimalTestGateway is true", async () => {
    const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js");
    await prepareGatewayPluginBootstrap({
      cfgAtStart: { channels: { telegram: { botToken: "t" } } } as unknown as Parameters<
        typeof prepareGatewayPluginBootstrap
      >[0]["cfgAtStart"],
      startupRuntimeConfig: { channels: { telegram: { botToken: "t" } } } as unknown as Parameters<
        typeof prepareGatewayPluginBootstrap
      >[0]["startupRuntimeConfig"],
      minimalTestGateway: true,
      log: createLog(),
    });

    expect(runStartupLegacyWorkspacesMigration).not.toHaveBeenCalled();
  });
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pnpm test src/gateway/server-startup-plugins.test.ts`
Expected: FAIL — both new tests fail (the migration is not wired in yet; `runStartupLegacyWorkspacesMigration` is never called).

- [ ] **Step 3: Wire the migration into `server-startup-plugins.ts`**

Edit `src/gateway/server-startup-plugins.ts`. Inside `prepareGatewayPluginBootstrap`, find the block starting at line 53:

```ts
  if (shouldRunStartupMaintenance) {
    const { runChannelPluginStartupMaintenance } =
      await import("../channels/plugins/lifecycle-startup.js");
    const startupTasks = [
```

Insert at the top of the `if` body (immediately after the opening `{`, before `const { runChannelPluginStartupMaintenance } = ...`):

```ts
    if (!params.minimalTestGateway) {
      const { runStartupLegacyWorkspacesMigration } = await import(
        "./server-startup-legacy-workspaces-migration.js"
      );
      await runStartupLegacyWorkspacesMigration({
        env: process.env,
        log: params.log,
      });
    }
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pnpm test src/gateway/server-startup-plugins.test.ts`
Expected: PASS (existing tests + 2 new ones).

Then run the new module's tests to confirm nothing regressed:

Run: `pnpm test src/gateway/server-startup-legacy-workspaces-migration.test.ts`
Expected: PASS (7 tests).

- [ ] **Step 5: Commit**

```bash
scripts/committer "feat(gateway): invoke legacy workspaces migration before plugin Promise.all" \
  src/gateway/server-startup-plugins.ts \
  src/gateway/server-startup-plugins.test.ts
```

---

## Task 7: Ship the scripts in the npm package + manual verification

**Files:**
- Modify: `package.json`

- [ ] **Step 1: Add the script paths to `files`**

Edit `package.json`. Locate the `"files"` array (currently starts around line 6 with `"CHANGELOG.md"`, `"LICENSE"`, etc.). Append two entries:

```json
    "scripts/migrate-legacy-sessions.mjs",
    "scripts/migrate-legacy-skills.mjs",
```

The relative position inside `files` doesn't matter; keep them alphabetically near each other. Make sure trailing commas are correct for valid JSON.

- [ ] **Step 2: Verify the scripts are picked up by `npm pack`**

Run: `npm pack --dry-run 2>&1 | grep -E "migrate-legacy-(sessions|skills)\.mjs"`
Expected output: two lines, both ending in the script filenames, indicating they're included in the tarball.

If neither line appears, the glob is wrong — re-check the `files` entries.

- [ ] **Step 3: Run changed-gate to catch lane regressions**

Run: `pnpm test:changed`
Expected: passes. This catches any unrelated test breakage from the touched paths.

- [ ] **Step 4: Manual smoke (optional, dev machine only)**

If on a machine with a legacy `~/.sealseek/workspaces/` and no prior sentinel:

1. `rm -f ~/.sealseek/.legacy-workspaces-migrated.json` (only if a stale one exists)
2. Restart gateway: `pnpm gateway:watch`
3. Watch stderr for `legacy workspaces migrated: sessions=success, skills=success` (or `nothing-to-do` if the scripts have nothing new to import on this machine because their own manifests are already populated).
4. Confirm `~/.sealseek/.legacy-workspaces-migrated.json` exists and contains the expected shape.
5. Stop gateway, restart again. Watch stderr — should see `sentinel present; skipping` at debug level (only visible with `OPENCLAW_LOG_SUBSYSTEM=gateway/legacy-workspaces-migration` or similar) and no script invocation.

- [ ] **Step 5: Commit**

```bash
scripts/committer "chore(package): ship legacy workspace migration scripts in npm files" \
  package.json
```

---

## Verification Checklist (End of Implementation)

- [ ] `pnpm test src/gateway/server-startup-legacy-workspaces-migration.test.ts` — 7 tests pass
- [ ] `pnpm test src/gateway/server-startup-plugins.test.ts` — existing + 2 new tests pass
- [ ] `pnpm test:changed` — green
- [ ] `pnpm tsgo` lane for gateway — no new type errors
- [ ] `npm pack --dry-run` shows both scripts in tarball
- [ ] Manual smoke: gateway logs migration line on first start, debug skip on second start, sentinel JSON present
- [ ] No new entries in CHANGELOG required (internal startup wiring — user-facing scripts already existed)
