# Task 1899 — reconcile reports and never moves — 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:** The standing account-dir reconcile stops moving files into `.quarantine/` and instead names every account-root entry the schema does not account for, and a one-shot script puts back what the sweep already took.

**Architecture:** `reconcileAccount` keeps computing strays and returns their names alongside the counts; the move block is deleted outright. `runReconcile` emits one `op=strays` line per affected account before the existing summary. A separate `platform/scripts/quarantine-restore.mjs` reads each account's quarantine manifest newest-first and moves entries back only where the account root has nothing at that name.

**Tech Stack:** TypeScript (Node16 ESM, `strict: true`), vitest for the service tests, plain `.mjs` + a bash `.test.sh` for the script, matching the `platform/scripts/` convention.

## Global Constraints

- Spec: `platform/docs/superpowers/specs/2026-07-22-task-1899-reconcile-report-only-design.md`. Task: `.tasks/1899-account-dir-reconcile-moves-live-state.md`.
- `PROTECTED_TOP_LEVEL` and `QUARANTINE_DIR` are kept, not deleted. They no longer protect anything from a move; they keep known platform entries and existing quarantine directories out of the stray count.
- Nothing in this plan deletes a file from disk at runtime. The reconcile never writes; the restore script only renames and appends.
- `over-deep` and `bad-name` behaviour is unchanged. Both already count without moving.
- The installer payload has no committed twin of any file touched here — `packages/create-maxy-code/scripts/bundle.js` copies `platform/` at bundle time. No twin edits.
- Every commit carries `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` and `Session: b141b27a-1d60-4de9-9f29-091ee2485814` trailers.
- Run all vitest commands from `platform/services/claude-session-manager`.

---

### Task 1: Reconcile reports and never moves

**Files:**
- Modify: `platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts:28-36` (imports), `:92-104` (types and formatter), `:202-211` (`quarantineDest`), `:213-278` (`reconcileAccount`)
- Test: `platform/services/claude-session-manager/src/__tests__/account-dir-schema-reconcile.test.ts`

**Interfaces:**
- Consumes: nothing from earlier tasks.
- Produces: `ReconcileCounts { strayTopLevel: number; overDeep: number; badName: number }` (no `quarantined`); `ReconcileResult extends ReconcileCounts { strayNames: string[] }`; `reconcileAccount(accountDir: string): ReconcileResult`; `formatReconcileLine(c: ReconcileCounts): string`.

The `now: () => number` parameter is removed from `reconcileAccount`, and `now?: () => number` is removed from `ReconcileDeps`. Its only reader was the manifest record's `movedAt` field, which this task deletes. Nothing else passes it: `index.ts` constructs the reconcile without it.

- [ ] **Step 1: Rewrite the formatter test to the three-field line**

In `src/__tests__/account-dir-schema-reconcile.test.ts`, replace the `formatReconcileLine` describe block with:

```typescript
describe('formatReconcileLine', () => {
  it('renders the greppable metric line', () => {
    expect(formatReconcileLine({ strayTopLevel: 2, overDeep: 1, badName: 3 })).toBe(
      '[fs-reconcile] stray-top-level=2 over-deep=1 bad-name=3',
    )
  })
})
```

- [ ] **Step 2: Replace the quarantine-move test with the no-move test**

Replace the `it('quarantines a stray top-level dir with a manifest entry', ...)` case (lines 68-84) with:

```typescript
  it('names a stray top-level dir and never moves it', () => {
    const dir = seedAccount('acct-a')
    mkdirSync(join(dir, '_attic'))
    writeFileSync(join(dir, '_attic', 'junk.txt'), 'x')
    const c = reconcileAccount(dir)
    expect(c.strayTopLevel).toBe(1)
    expect(c.strayNames).toEqual(['_attic'])
    // The entry stays exactly where the platform (or the agent) put it, and no
    // quarantine directory is created. A closed allowed-list that lags what the
    // platform writes turned this move into data loss (Task 1899).
    expect(existsSync(join(dir, '_attic', 'junk.txt'))).toBe(true)
    expect(existsSync(join(dir, '.quarantine'))).toBe(false)
  })
```

- [ ] **Step 3: Strip `quarantined` and `FIXED_NOW` from the remaining cases**

Every remaining `expect(c.quarantined)...` assertion is deleted. Every `reconcileAccount(dir, FIXED_NOW)` call becomes `reconcileAccount(dir)`, and the `const FIXED_NOW = () => 1_700_000_000_000` declaration at line 26 is deleted along with the `now: FIXED_NOW` property in each `runReconcile` call. The cases that asserted a stray landed under `.quarantine/` assert instead that it is still at the root and named. Concretely:

`'never quarantines dot-prefixed top-level tool state, only non-dot strays'` becomes:

```typescript
  it('does not count dot-prefixed top-level tool state as a stray, only non-dot entries', () => {
    const dir = seedAccount('acct-dotdirs')
    // dot-prefixed tool/system dirs — never counted.
    for (const d of ['.remember', '.superpowers', '.wrangler', '.uploads-tmp', '.archive']) {
      mkdirSync(join(dir, d))
      writeFileSync(join(dir, d, 'state.txt'), 'x')
    }
    // a genuine non-dot operator stray — counted and named.
    mkdirSync(join(dir, 'workers'))
    const c = reconcileAccount(dir)
    expect(c.strayTopLevel).toBe(1)
    expect(c.strayNames).toEqual(['workers'])
    for (const d of ['.remember', '.superpowers', '.wrangler', '.uploads-tmp', '.archive']) {
      expect(existsSync(join(dir, d))).toBe(true)
    }
    expect(existsSync(join(dir, 'workers'))).toBe(true)
  })
```

`'never quarantines platform-written control-plane root files absent from the allowed block'` becomes:

```typescript
  it('never counts platform-written control-plane root files absent from the allowed block', () => {
    // The account's SCHEMA.md allowed block lists only buckets, never these
    // platform-written root files. PROTECTED_TOP_LEVEL keeps them out of the
    // stray count so the standing line stays readable; a genuine operator stray
    // alongside them is still counted and named.
    const dir = seedAccount('acct-control-files')
    const controlFiles = [
      'calendar-availability.json',
      'wa-channel-bindings.json',
      'webchat-channel-bindings.json',
      'telegram-channel-bindings.json',
      'canonical-webchat-session.json',
      'session-titles.json',
    ]
    for (const f of controlFiles) writeFileSync(join(dir, f), '{}')
    mkdirSync(join(dir, 'workers')) // a genuine operator stray
    const c = reconcileAccount(dir)
    expect(c.strayTopLevel).toBe(1)
    expect(c.strayNames).toEqual(['workers'])
    for (const f of controlFiles) expect(existsSync(join(dir, f))).toBe(true)
    expect(existsSync(join(dir, 'workers'))).toBe(true)
  })
```

`'never quarantines control-plane entries even when a stale schema omits them'` becomes:

```typescript
  it('never counts control-plane entries even when a stale schema omits them', () => {
    // A stale/hand-edited schema whose allowed block lacks .git/.claude/account.json.
    const dir = join(root, 'acct-stale')
    mkdirSync(dir, { recursive: true })
    writeFileSync(
      join(dir, 'SCHEMA.md'),
      '# schema\n```allowed-top-level\nprojects\n.quarantine\n```\n',
    )
    writeFileSync(join(dir, 'account.json'), '{}')
    mkdirSync(join(dir, '.git'))
    mkdirSync(join(dir, '.claude'))
    writeFileSync(join(dir, 'AGENTS.md'), 'x')
    mkdirSync(join(dir, 'workers')) // a genuine operator stray
    const c = reconcileAccount(dir)
    expect(c.strayTopLevel).toBe(1)
    expect(c.strayNames).toEqual(['workers'])
    expect(existsSync(join(dir, '.git'))).toBe(true)
    expect(existsSync(join(dir, '.claude'))).toBe(true)
    expect(existsSync(join(dir, 'account.json'))).toBe(true)
    expect(existsSync(join(dir, 'SCHEMA.md'))).toBe(true)
    expect(existsSync(join(dir, 'AGENTS.md'))).toBe(true)
    expect(existsSync(join(dir, 'workers'))).toBe(true)
  })
```

The `'skips an account with no SCHEMA.md (fail open)'` case's `toEqual` becomes:

```typescript
    expect(c).toEqual({ strayTopLevel: 0, overDeep: 0, badName: 0, strayNames: [] })
```

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

Run: `npx vitest run src/__tests__/account-dir-schema-reconcile.test.ts`
Expected: FAIL. TypeScript rejects `formatReconcileLine` called without `quarantined`, and `c.strayNames` does not exist on `ReconcileCounts`.

- [ ] **Step 5: Delete the move, add `strayNames`**

In `src/account-dir-schema-reconcile.ts`:

Imports become:

```typescript
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative, sep } from 'node:path'
```

Types and formatter become:

```typescript
export interface ReconcileCounts {
  strayTopLevel: number
  overDeep: number
  badName: number
}

export interface ReconcileResult extends ReconcileCounts {
  /** Every account-root entry the schema does not account for, by name. A count
   *  alone cannot distinguish six pieces of debris from a live plugin config;
   *  this list is what makes the report-only posture actionable. */
  strayNames: string[]
}

/** The single per-run summary line body. A non-zero count is the standing signal
 *  that an agent authored folders the hard guards could not prevent. Pure
 *  formatter so the emitter and its test share one wording. */
export function formatReconcileLine(c: ReconcileCounts): string {
  return `${TAG} stray-top-level=${c.strayTopLevel} over-deep=${c.overDeep} bad-name=${c.badName}`
}
```

Delete `quarantineDest` entirely (lines 202-211). `reconcileAccount` becomes:

```typescript
/** Reconcile one account dir. Reads its SCHEMA.md allowed set; an absent/empty
 *  set skips the account (fail open, like the hook — an un-seeded legacy account
 *  must not have its whole root reported as stray). Counts and names stray
 *  top-level entries; counts over-deep files and bad names. Moves nothing. */
export function reconcileAccount(accountDir: string): ReconcileResult {
  const empty: ReconcileResult = { strayTopLevel: 0, overDeep: 0, badName: 0, strayNames: [] }
  const schemaPath = join(accountDir, 'SCHEMA.md')
  if (!existsSync(schemaPath)) return empty
  let allowed: Set<string>
  try {
    allowed = parseAllowedTopLevel(readFileSync(schemaPath, 'utf-8'))
  } catch {
    return empty
  }
  if (allowed.size === 0) return empty

  let rootEntries: string[]
  try {
    rootEntries = readdirSync(accountDir)
  } catch {
    return empty
  }

  // A dot-prefixed top-level entry is tool/system state, never operator data —
  // the same judgement countBadNameEntries applies when it skips dotfiles. The
  // stray check must agree, or it reports .remember/.superpowers/.wrangler on
  // every run (Task 1891). .git/.claude/.quarantine stay covered by
  // PROTECTED_TOP_LEVEL regardless.
  const strays = rootEntries.filter(
    (name) => !name.startsWith('.') && !PROTECTED_TOP_LEVEL.has(name) && !allowed.has(name),
  )

  let overDeep = 0
  let badName = 0
  for (const bucket of DEPTH_CHECKED_BUCKETS) {
    overDeep += countOverDeepFiles(accountDir, bucket)
    badName += countBadNameEntries(accountDir, bucket)
  }

  return { strayTopLevel: strays.length, overDeep, badName, strayNames: strays }
}
```

Rewrite the file's header comment so its third paragraph reads:

```
// Per account, per run it detects (a) stray top-level entries — any account-root
// entry not in that account's allowed-top-level set — (b) over-deep files under
// the flat operator-data buckets, mirroring fs-schema-guard.sh's own depth rule
// exactly (projects/contacts/documents cap at 3 path segments; every other
// tool-owned bucket is unbounded and not walked), and (c) bad-named folders and
// files under those same buckets, mirroring the guard's name convention. All
// three are REPORTED, never moved: the pass used to quarantine stray top-level
// entries, and because its allowed-list lagged what the platform and its agents
// actually write at an account root, every mismatch took a live feature or live
// data dark with no error anywhere (Task 1899 — the portal config, live signed
// agreements and the channel bindings all went). Nothing is moved, nothing is
// deleted, nothing is silently rewritten; re-filing a stray to its schema-correct
// home is the data-manager specialist's job on dispatch, not this pass.
```

Update the `PROTECTED_TOP_LEVEL` doc comment's first sentence to `Platform control-plane entries the reconcile NEVER counts as strays` and its closing sentence to say the belt keeps the account's own machinery out of the stray count.

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

Run: `npx vitest run src/__tests__/account-dir-schema-reconcile.test.ts`
Expected: PASS.

- [ ] **Step 7: Mutation check**

Re-add a `renameSync(join(accountDir, name), join(accountDir, QUARANTINE_DIR, name))` call guarded by `mkdirSync(join(accountDir, QUARANTINE_DIR), { recursive: true })` inside a `for (const name of strays)` loop before the `return`. Run the tests: the `'names a stray top-level dir and never moves it'` case must FAIL on `existsSync(join(dir, '.quarantine'))`. Revert.

Then delete `'workers'` from the `strayNames` return (`strayNames: []`). Run the tests: the naming assertions must FAIL. Revert.

- [ ] **Step 8: Commit**

```bash
git add maxy-code/platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts \
        maxy-code/platform/services/claude-session-manager/src/__tests__/account-dir-schema-reconcile.test.ts
git commit -m "fix(reconcile): report stray top-level entries, never move them (Task 1899)"
```

---

### Task 2: `runReconcile` names the strays per account

**Files:**
- Modify: `platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts:280-314` (`ReconcileDeps`, `runReconcile`)
- Test: `platform/services/claude-session-manager/src/__tests__/account-dir-schema-reconcile.test.ts`

**Interfaces:**
- Consumes: `reconcileAccount(accountDir: string): ReconcileResult` from Task 1.
- Produces: `ReconcileDeps { accountsRoot: string; logger: (line: string) => void }` (no `now`); `runReconcile(deps: ReconcileDeps): void`, which emits zero or more `op=strays` lines followed by exactly one summary line.

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

Replace the `runReconcile` describe block with:

```typescript
describe('runReconcile', () => {
  it('names each account with strays, then logs one summary line', () => {
    seedAccount('clean')
    const dirty = seedAccount('dirty')
    mkdirSync(join(dirty, 'workers'))
    writeFileSync(join(dirty, 'data-portal.json'), '{}')
    const lines: string[] = []
    runReconcile({ accountsRoot: root, logger: (l) => lines.push(l) })
    expect(lines).toEqual([
      '[fs-reconcile] op=strays account=dirty names=data-portal.json,workers',
      '[fs-reconcile] stray-top-level=2 over-deep=0 bad-name=0',
    ])
    // Reported, not moved.
    expect(existsSync(join(dirty, 'workers'))).toBe(true)
    expect(existsSync(join(dirty, 'data-portal.json'))).toBe(true)
    expect(existsSync(join(root, 'dirty', '.quarantine'))).toBe(false)
  })

  it('emits no op=strays line when every account root is clean', () => {
    seedAccount('clean-a')
    seedAccount('clean-b')
    const lines: string[] = []
    runReconcile({ accountsRoot: root, logger: (l) => lines.push(l) })
    expect(lines).toEqual(['[fs-reconcile] stray-top-level=0 over-deep=0 bad-name=0'])
  })

  it('logs zeros when the accounts root is unreadable', () => {
    const lines: string[] = []
    runReconcile({ accountsRoot: join(root, 'does-not-exist'), logger: (l) => lines.push(l) })
    expect(lines).toEqual(['[fs-reconcile] stray-top-level=0 over-deep=0 bad-name=0'])
  })

  it('ignores a child dir that is not an account (no account.json)', () => {
    seedAccount('real')
    mkdirSync(join(root, 'not-an-account'))
    mkdirSync(join(root, 'not-an-account', 'whim'))
    const lines: string[] = []
    runReconcile({ accountsRoot: root, logger: (l) => lines.push(l) })
    expect(lines).toEqual(['[fs-reconcile] stray-top-level=0 over-deep=0 bad-name=0'])
    expect(existsSync(join(root, 'not-an-account', 'whim'))).toBe(true)
  })
})
```

`readdirSync` returns entries in directory order, and the first test asserts `data-portal.json,workers` — alphabetical because `readdirSync` on both macOS (APFS) and ext4 with `dir_index` returns them so for this fixture. If the assertion is order-flaky in CI, sort `strayNames` in `reconcileAccount` rather than loosening the assertion; sorted output is also easier to read in a log.

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

Run: `npx vitest run src/__tests__/account-dir-schema-reconcile.test.ts`
Expected: FAIL. Only the summary line is emitted; the `op=strays` line is missing.

- [ ] **Step 3: Emit the per-account line**

```typescript
export interface ReconcileDeps {
  accountsRoot: string
  logger: (line: string) => void
}

/** One reconcile pass over every account under the accounts root: one op=strays
 *  line naming the unaccounted-for entries of each account that has any, then the
 *  single summary line. An account is any child dir with an account.json (the
 *  account marker the census uses). Per-account errors are swallowed so one bad
 *  account never aborts the run.
 *
 *  The op=strays line is the standing signal. When a feature that reads an
 *  account-root file goes dark, the file named here means the schema does not
 *  account for it and the platform is still reading it, so declare it; no line
 *  naming it means the file is genuinely gone and the fault lies with whatever
 *  last wrote it. */
export function runReconcile(deps: ReconcileDeps): void {
  const total: ReconcileCounts = { strayTopLevel: 0, overDeep: 0, badName: 0 }
  let entries: string[]
  try {
    entries = readdirSync(deps.accountsRoot)
  } catch {
    deps.logger(formatReconcileLine(total))
    return
  }
  for (const entry of entries) {
    const accountDir = join(deps.accountsRoot, entry)
    if (!existsSync(join(accountDir, 'account.json'))) continue
    try {
      const c = reconcileAccount(accountDir)
      if (c.strayNames.length > 0) {
        deps.logger(`${TAG} op=strays account=${entry} names=${c.strayNames.join(',')}`)
      }
      total.strayTopLevel += c.strayTopLevel
      total.overDeep += c.overDeep
      total.badName += c.badName
    } catch {
      // best-effort: skip an account that throws unexpectedly
    }
  }
  deps.logger(formatReconcileLine(total))
}
```

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

Run: `npx vitest run src/__tests__/account-dir-schema-reconcile.test.ts`
Expected: PASS, 18 or more tests.

- [ ] **Step 5: Build the service**

Run: `cd ../.. && npx tsc -p services/claude-session-manager/tsconfig.json`
Expected: no output, exit 0.

- [ ] **Step 6: Commit**

```bash
git add maxy-code/platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts \
        maxy-code/platform/services/claude-session-manager/src/__tests__/account-dir-schema-reconcile.test.ts
git commit -m "feat(reconcile): name unaccounted-for account-root entries per account (Task 1899)"
```

---

### Task 3: Correct the two prose surfaces that describe the move

**Files:**
- Modify: `platform/services/claude-session-manager/src/index.ts:860-867`
- Modify: `platform/templates/account-schema/SCHEMA.md:34-37`

**Interfaces:**
- Consumes: the behaviour established in Tasks 1 and 2.
- Produces: nothing consumed by later tasks.

- [ ] **Step 1: Rewrite the call-site comment**

Replace the comment block at `index.ts:860-867` with:

```typescript
  // Task 1877 / Task 1899 — standing account-dir schema reconcile. The
  // fs-schema-guard path hook never sees Bash, and the immediate Bash hooks only
  // catch strays a live command newly creates; neither retroactively catches the
  // backlog an agent authored before the hooks existed. Folder creation through
  // Bash is a no-event failure, so this periodic filesystem pass is the only
  // signal. It REPORTS and never moves: it names every account-root entry the
  // schema does not account for (op=strays) and counts over-deep files and bad
  // names, emitting one [fs-reconcile] metric line per run. It used to quarantine
  // strays, and because a closed allowed-list always lags what the platform
  // writes, that turned a metric into data loss. Purely filesystem — no Neo4j.
```

- [ ] **Step 2: Rewrite the account SCHEMA.md template paragraph**

Replace `platform/templates/account-schema/SCHEMA.md:34-37`:

```
`.quarantine/` is a historical store. An earlier version of the schema reconcile
moved entries out of the top level into it, recording each move in
`.quarantine/manifest.jsonl`; the reconcile now reports unrecognised entries and
never moves anything, so nothing is added to it. Re-file whatever it still holds
to its correct home through the `data-manager` specialist; never author into it
directly.
```

- [ ] **Step 3: Verify the guard's expected top-level set is unchanged**

Run: `bash platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh`
Expected: all assertions pass. `.quarantine` stays in the allowed set, so the write guard is unaffected.

- [ ] **Step 4: Build to confirm the comment edit broke nothing**

Run: `cd platform && npx tsc -p services/claude-session-manager/tsconfig.json`
Expected: no output, exit 0.

- [ ] **Step 5: Commit**

```bash
git add maxy-code/platform/services/claude-session-manager/src/index.ts \
        maxy-code/platform/templates/account-schema/SCHEMA.md
git commit -m "docs(reconcile): call site and account schema state that the pass never moves (Task 1899)"
```

---

### Task 4: `quarantine-restore.mjs`

**Files:**
- Create: `platform/scripts/quarantine-restore.mjs`
- Test: `platform/scripts/__tests__/quarantine-restore.test.sh`

**Interfaces:**
- Consumes: the manifest record shape the deleted move block wrote — `{ name, originalPath, reason, quarantinePath, movedAt }`, one JSON object per line, appended in move order.
- Produces: a standalone CLI. Nothing imports it.

Invocation: `node platform/scripts/quarantine-restore.mjs --accounts-root <dir> [--dry-run]`.

Per account (any child of `--accounts-root` holding `.quarantine/manifest.jsonl`), records are walked newest-first, which is the reverse of file order because the manifest is append-only. Newest-first is what makes the occupied case correct for a name quarantined more than once: the most recent copy is considered first, and every older copy of that name then finds the target occupied. Checks run in this order, and the first that matches decides:

| Condition | Action | Log |
|---|---|---|
| `<account>/<quarantinePath>` absent | none | `result=absent` |
| `<account>/<originalPath>` exists | none | `result=skipped-live-copy` |
| otherwise | rename source to target, append to `restored.jsonl` | `result=moved` |

A manifest line that is not parseable JSON, or whose `originalPath`/`quarantinePath` is missing or not a string, logs `result=unparsable` with its 1-based line number. It is neither moved nor silently dropped: a silently skipped line is the exact failure class this task exists to end.

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

Create `platform/scripts/__tests__/quarantine-restore.test.sh`:

```bash
#!/usr/bin/env bash
# Tests the one-shot quarantine restore (Task 1899): a free target is moved back,
# an occupied target is skipped with both copies surviving, an already-restored
# entry reports absent, a malformed line reports unparsable, and --dry-run
# changes nothing on disk.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RESTORE="$SCRIPT_DIR/../quarantine-restore.mjs"
PASS=0; FAIL=0; FAILED=()
assert_grep()   { if printf '%s' "$2" | grep -qF "$1"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$3: expected to find [$1]"); fi; }
assert_nogrep() { if printf '%s' "$2" | grep -qF "$1"; then FAIL=$((FAIL+1)); FAILED+=("$3: expected NOT to find [$1]"); else PASS=$((PASS+1)); fi; }
assert_file()   { if [ -e "$1" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2: expected file [$1]"); fi; }
assert_nofile() { if [ -e "$1" ]; then FAIL=$((FAIL+1)); FAILED+=("$2: expected NO file [$1]"); else PASS=$((PASS+1)); fi; }

# One accounts root with a single account whose manifest covers all five cases.
make_tree() {
  local T; T="$(mktemp -d)"
  local A="$T/acct-1"
  mkdir -p "$A/.quarantine"
  printf '{}' > "$A/account.json"

  # (a) free target: quarantined copy exists, nothing at the root.
  printf 'portal' > "$A/.quarantine/data-portal.json"
  # (b) occupied target: quarantined copy AND a live copy the platform rewrote.
  printf 'old' > "$A/.quarantine/wa-channel-bindings.json"
  printf 'live' > "$A/wa-channel-bindings.json"
  # (c) already restored by hand: manifest names a source that is gone.
  printf 'restored' > "$A/session-titles.json"

  cat > "$A/.quarantine/manifest.jsonl" <<'EOF'
{"name":"data-portal.json","originalPath":"data-portal.json","reason":"top-level","quarantinePath":".quarantine/data-portal.json","movedAt":"2026-07-21T19:16:05.216Z"}
{"name":"session-titles.json","originalPath":"session-titles.json","reason":"top-level","quarantinePath":".quarantine/session-titles.json","movedAt":"2026-07-21T19:20:00.000Z"}
not json at all
{"name":"wa-channel-bindings.json","originalPath":"wa-channel-bindings.json","reason":"top-level","quarantinePath":".quarantine/wa-channel-bindings.json","movedAt":"2026-07-21T20:45:00.000Z"}
EOF
  printf '%s' "$T"
}

# --- dry run changes nothing -------------------------------------------------
T="$(make_tree)"; A="$T/acct-1"
OUT="$(node "$RESTORE" --accounts-root "$T" --dry-run 2>&1)"
assert_grep 'result=moved' "$OUT" "dry-run reports the move it would make"
assert_grep 'dry-run=1' "$OUT" "dry-run marks its lines"
assert_nofile "$A/data-portal.json" "dry-run leaves the root untouched"
assert_file "$A/.quarantine/data-portal.json" "dry-run leaves the quarantined copy"
assert_nofile "$A/.quarantine/restored.jsonl" "dry-run writes no restored.jsonl"
rm -rf "$T"

# --- real run ----------------------------------------------------------------
T="$(make_tree)"; A="$T/acct-1"
OUT="$(node "$RESTORE" --accounts-root "$T" 2>&1)"

assert_grep 'op=restore account=acct-1 name=data-portal.json result=moved' "$OUT" "free target moved"
assert_file "$A/data-portal.json" "free target is back at the root"
assert_nofile "$A/.quarantine/data-portal.json" "free target no longer in quarantine"

assert_grep 'name=wa-channel-bindings.json result=skipped-live-copy' "$OUT" "occupied target skipped"
assert_grep 'live' "$(cat "$A/wa-channel-bindings.json")" "live copy still wins"
assert_file "$A/.quarantine/wa-channel-bindings.json" "quarantined copy survives the skip"

assert_grep 'name=session-titles.json result=absent' "$OUT" "already-restored source reports absent"
assert_grep 'restored' "$(cat "$A/session-titles.json")" "hand-restored copy untouched"

assert_grep 'line=3 result=unparsable' "$OUT" "malformed manifest line is reported"

assert_file "$A/.quarantine/manifest.jsonl" "manifest is left intact"
RESTORED="$(cat "$A/.quarantine/restored.jsonl")"
assert_grep 'data-portal.json' "$RESTORED" "restored.jsonl records the move"
assert_nogrep 'wa-channel-bindings.json' "$RESTORED" "restored.jsonl records only moves"
assert_nogrep 'session-titles.json' "$RESTORED" "restored.jsonl records only moves"
rm -rf "$T"

# --- summary -----------------------------------------------------------------
echo "PASS=$PASS FAIL=$FAIL"
if [ "$FAIL" -gt 0 ]; then printf '%s\n' "${FAILED[@]}"; exit 1; fi
```

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

Run: `bash platform/scripts/__tests__/quarantine-restore.test.sh`
Expected: FAIL. `node` cannot find `platform/scripts/quarantine-restore.mjs`.

- [ ] **Step 3: Write the script**

Create `platform/scripts/quarantine-restore.mjs`:

```javascript
#!/usr/bin/env node
// Task 1899 — one-shot restore of everything the account-dir schema reconcile
// moved into <account>/.quarantine/ before it was made report-only. The sweep's
// allowed-list never matched what the platform and its agents write at an
// account root, so it took live features and live data dark with no error: the
// portal config, live signed agreements, the channel bindings.
//
// Per account it walks .quarantine/manifest.jsonl NEWEST-FIRST (the manifest is
// append-only, so that is reverse file order). Newest-first is what makes the
// occupied case correct for a name quarantined more than once: the most recent
// copy is considered first, and every older copy of that name then finds the
// target occupied. The live copy always wins, because the platform rewrote it
// after the move — four wa-channel-bindings.json records on one account prove
// that happened.
//
// Never deletes. The manifest is left intact so the history of what moved and
// what came back both survive.

import { existsSync, readdirSync, readFileSync, renameSync, appendFileSync } from 'node:fs'
import { join } from 'node:path'

const TAG = '[quarantine-restore]'
const QUARANTINE_DIR = '.quarantine'

function parseArgs(argv) {
  let accountsRoot = null
  let dryRun = false
  for (let i = 0; i < argv.length; i++) {
    if (argv[i] === '--accounts-root') {
      accountsRoot = argv[i + 1] ?? null
      i++
    } else if (argv[i] === '--dry-run') {
      dryRun = true
    }
  }
  return { accountsRoot, dryRun }
}

const { accountsRoot, dryRun } = parseArgs(process.argv.slice(2))
if (!accountsRoot) {
  console.error(`${TAG} usage: quarantine-restore.mjs --accounts-root <dir> [--dry-run]`)
  process.exit(1)
}
if (!existsSync(accountsRoot)) {
  console.error(`${TAG} accounts root not found: ${accountsRoot}`)
  process.exit(1)
}

const suffix = dryRun ? ' dry-run=1' : ''
const totals = { moved: 0, 'skipped-live-copy': 0, absent: 0, unparsable: 0 }
let accounts = 0

for (const entry of readdirSync(accountsRoot)) {
  const accountDir = join(accountsRoot, entry)
  const manifestPath = join(accountDir, QUARANTINE_DIR, 'manifest.jsonl')
  if (!existsSync(manifestPath)) continue
  accounts++

  let lines
  try {
    lines = readFileSync(manifestPath, 'utf-8').split('\n')
  } catch (err) {
    console.error(`${TAG} op=restore account=${entry} result=manifest-unreadable error=${err.message}`)
    continue
  }

  // Newest-first: the manifest is append-only, so walk it backwards. The 1-based
  // file line number is carried so an unparsable line can be pointed at.
  const numbered = lines.map((text, i) => ({ text, lineNo: i + 1 })).filter((l) => l.text.trim())
  for (const { text, lineNo } of numbered.reverse()) {
    let rec
    try {
      rec = JSON.parse(text)
    } catch {
      rec = null
    }
    if (!rec || typeof rec.originalPath !== 'string' || typeof rec.quarantinePath !== 'string') {
      totals.unparsable++
      console.log(`${TAG} op=restore account=${entry} line=${lineNo} result=unparsable${suffix}`)
      continue
    }

    const source = join(accountDir, rec.quarantinePath)
    const target = join(accountDir, rec.originalPath)
    const name = rec.originalPath

    if (!existsSync(source)) {
      totals.absent++
      console.log(`${TAG} op=restore account=${entry} name=${name} result=absent${suffix}`)
      continue
    }
    if (existsSync(target)) {
      totals['skipped-live-copy']++
      console.log(`${TAG} op=restore account=${entry} name=${name} result=skipped-live-copy${suffix}`)
      continue
    }

    if (dryRun) {
      totals.moved++
      console.log(`${TAG} op=restore account=${entry} name=${name} result=moved${suffix}`)
      continue
    }
    try {
      renameSync(source, target)
      appendFileSync(
        join(accountDir, QUARANTINE_DIR, 'restored.jsonl'),
        JSON.stringify({ ...rec, restoredAt: new Date().toISOString() }) + '\n',
      )
      totals.moved++
      console.log(`${TAG} op=restore account=${entry} name=${name} result=moved`)
    } catch (err) {
      console.error(`${TAG} op=restore account=${entry} name=${name} result=failed error=${err.message}`)
    }
  }
}

console.log(
  `${TAG} accounts=${accounts} moved=${totals.moved} skipped-live-copy=${totals['skipped-live-copy']} absent=${totals.absent} unparsable=${totals.unparsable}${suffix}`,
)
```

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

Run: `bash platform/scripts/__tests__/quarantine-restore.test.sh`
Expected: `PASS=<n> FAIL=0`, exit 0.

- [ ] **Step 5: Mutation check**

Swap the source-absent and target-occupied checks so the occupied test runs first. Run the test: the `result=absent` assertion must FAIL (`session-titles.json` would report `skipped-live-copy`). Revert.

Then change `numbered.reverse()` to `numbered`. Run the test: it still passes on this fixture, because no name repeats in it. Add a second `data-portal.json` record with an older timestamp and a `.quarantine/data-portal.json.1` source; oldest-first then restores the older copy. Revert both.

- [ ] **Step 6: Commit**

```bash
git add maxy-code/platform/scripts/quarantine-restore.mjs \
        maxy-code/platform/scripts/__tests__/quarantine-restore.test.sh
git commit -m "feat(scripts): one-shot restore of quarantined account-root entries (Task 1899)"
```

---

## Verification (whole plan)

Run from `platform/services/claude-session-manager`:

```bash
npx vitest run src/__tests__/account-dir-schema-reconcile.test.ts
```

Run from `platform`:

```bash
npx tsc -p services/claude-session-manager/tsconfig.json
bash scripts/__tests__/quarantine-restore.test.sh
bash plugins/admin/hooks/__tests__/fs-schema-guard.test.sh
```

The device checks in the task (`data-portal.json` and `e-sign/` surviving three consecutive `[fs-reconcile]` lines on `sitedesk-code` account `2078cb54`; `op=enumerate` returning `accounts=2`; a planted `zzz-not-in-schema/` being named by `op=strays` and left in place) all require the new build installed on the laptop, which follows the operator's publish command and is outside this plan.
