# Task 1877 — Bash schema enforcement + standing reconcile — implementation plan

Closes the Bash channel gap in account-dir schema enforcement. Two layers, per
the approved task decision. No agent-tool narrowing, no command-string parsing,
no auto-repair.

## Layer A — immediate Bash stray detection (two hooks)

Both live in `platform/plugins/admin/hooks/`, beside `fs-schema-guard.sh`. Both
key off account-root state (`$PWD` is the account dir for these hooks), never off
the command string.

### `fs-schema-guard-bash-pre.sh` (PreToolUse, matcher `Bash`)
- Read stdin envelope, extract `tool_name` and `session_id` (python3, matching
  `fs-schema-guard.sh`'s `field()` helper and the session-id extraction in
  `askuserquestion-channel-carrier-gate.sh`).
- Non-`Bash` tool: `exit 0`.
- Resolve `TMP_DIR` with the exact `TMPDIR:-TMP:-TEMP:-/tmp` order and trailing-
  slash strip used by `askuserquestion-channel-carrier-gate.sh`; sanitise the
  session id `[^A-Za-z0-9_-]` to `_`.
- List the account root's immediate top-level entry names (one per line, python3
  `os.listdir`) and write to `$TMP_DIR/maxy-fsguard-snapshot-<sid>.txt`.
- Fail open on every error (no session id, no python3, unlistable dir, unwritable
  tmp): `exit 0`, take no snapshot. A snapshot that cannot be taken must not
  block the command.

### `fs-schema-guard-bash-post.sh` (PostToolUse, matcher `Bash`)
- Same envelope parse; non-`Bash`: `exit 0`.
- Read the pre-snapshot for this session; missing snapshot: `exit 0` (fail open).
- Parse `allowed-top-level` from `$PWD/SCHEMA.md` with the same awk extraction
  `fs-schema-guard.sh` uses; empty/absent allowed set: `exit 0` (fail open — an
  un-seeded legacy account must not have every Bash call blocked).
- Re-list the account root now. Strays = entries **present now AND absent from
  the pre-snapshot AND not in the allowed set**.
- No strays: `exit 0` silent.
- For each stray, log `[fs-guard-bash] stray path=<name> reason=top-level` to
  stderr; then `exit 2` with a schema-citing message telling the agent to
  relocate through the `data-manager` specialist. No filesystem mutation
  (PostToolUse runs after the command; auto-repair is rejected).

## Layer B — standing reconcile

### `platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts`
Shape mirrors `rootless-client-audit.ts`: pure formatter (unit-tested) + impure
collector + `create…Reconcile` start/stop factory with an unref'd `setInterval`.
Purely filesystem: no Neo4j, no graph writes, no deletes.

Per account under the accounts root, per run:
- **Stray top-level:** any root entry not in that account's `allowed-top-level`
  set (parsed from its `SCHEMA.md`). Accounts with no/empty allowed set are
  skipped (fail open, like the hook).
- **Over-deep:** under the depth-checked buckets only, mirroring
  `fs-schema-guard.sh`'s `case` statement exactly — `projects`/`contacts` flat to
  3 segments, `documents` to 3; every other (tool-owned) bucket is not walked.
  This is the reuse of the guard's own encoding the task calls for, not a new
  list.
- **Quarantine, never repair:** move each stray top-level entry into the
  account's `.quarantine/` with a `manifest.jsonl` line recording original path +
  reason + timestamp. Over-deep files are counted only (not moved and not
  manifest-logged): moving a file out of a flat bucket would orphan it from its
  entity folder; re-filing is `data-manager`'s job.
- **Control-plane safety belt:** the reconcile never quarantines the platform
  control-plane entries (`.git`, `.claude`, `.quarantine`, `SCHEMA.md`,
  `account.json`, `AGENTS.md`) regardless of the account's allowed set, so a
  stale or hand-edited SCHEMA.md cannot drive it to move the account's git root,
  hook settings, or identity file. This is a deliberate deviation from the task's
  strict "any entry not in allowed" wording, chosen because a 5-minute auto-move
  loop that can brick an account on a stale schema is an unacceptable foot-gun.
- Emit one line per run: `[fs-reconcile] stray-top-level=<n> over-deep=<m>
  quarantined=<q>`.

Timestamps come from an injected clock (the module never calls `Date.now()` at
import; the factory passes `() => Date.now()`), so the pure formatter and
collector stay deterministic under test.

### Wiring
- `index.ts`: construct `createAccountDirSchemaReconcile({ accountsRoot:
  dirname(config.accountDir), logger: log, intervalMs: 300_000 })` beside the
  sibling audits and call `.start()`.

## Registration + seeding
- `provision-account-dir.sh`: add `fs-schema-guard-bash-pre.sh` to the existing
  PreToolUse `Bash` matcher (after `archive-ingest-surface-gate.sh`); add a new
  PostToolUse `Bash` matcher carrying `fs-schema-guard-bash-post.sh`.
- `templates/account-schema/SCHEMA.md`: add `.quarantine` to the
  `allowed-top-level` block so the reconcile's own move never creates a stray.

## Tests
- `platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh` — pre
  writes snapshot; post blocks a whim folder with the log line; allowed
  top-level and nested-allowed produce no block; no-new-entry is silent; a
  pre-existing stray is not re-flagged; fail-open on missing snapshot / empty
  allowed set.
- `platform/services/claude-session-manager/src/__tests__/account-dir-schema-reconcile.test.ts`
  — formatter output; planted stray moved to `.quarantine/` with manifest;
  allowed dir and tool-owned deep files untouched; over-deep counted not moved;
  clean account logs zeros and moves nothing.

## Verification surface
Build (`tsc`), lint, the new + existing `fs-schema-guard` tests, the manager
vitest suite. Installer publish awaits explicit operator command (sprint gating).

## Out of scope (per task)
Graph-reconcile/broken-refs passes (original 1092 graph half); auto-re-filing
quarantined content; agent-tool narrowing; command-string parsing; `chattr`
immutability; one-time cleanup of the existing backlog (a `data-manager`
dispatch).
