# estate-sync

`ai.stravica.ops.estate-sync` — single-writer-per-host git sync for
ESTATE-NATIVE content only.

The sole git-writer on a host running HQ scheduled tools, for the paths
this tool actually owns. Cron tools (distill, wsd-ingest,
upstream-triage, …) become pure file writers; this tool is the one
process per host that ever `git add` / `git commit` / `git push`. That
structural rule eliminates the intra-host write race that previously
caused `fail-rebase` and `fail-dirty-tree` cron alerts.

**Renamed from `dave-sync`** (library-sync-solidification, D1). The
rename is not cosmetic: the rewrite that ships alongside it replaces a
blind `git add -A` with a REGISTRY-SCOPED commit set. Task/schedule ids
that reference this tool by its estate-side name (e.g.
`schedules/dave-sync.json`) are estate data, out of scope for this
package — they don't need to change for the tool to work; the bin alias
below covers that gap during migration.

## Status

Implemented: `run`, `run --dry-run`, `--help`.

## Install / invoke

```
estate-sync run
estate-sync run --dry-run
estate-sync --help
```

`sync` is kept as a back-compat alias — identical bin, identical
behaviour, same entry point (`src/sync/cli.mjs`). Existing crontab /
schedule entries invoking `sync run` keep working unchanged; new installs
should prefer `estate-sync run`.

Designed to run every 30 minutes on each HQ host (smarthome01 + optional
macbook). The companion schedule definition is at
`schedules/dave-sync.json` (operator instance) — renaming that schedule
id/tag is estate-side work, not part of this package.

## Commands

### `estate-sync run`

1. **Acquire process lock.** A PID file at `<repoRoot>/.cache/estate-sync/.lock`
   ensures no two sync runs overlap on a host. The lock is considered
   stale after 10 minutes (a real run never legitimately takes that long;
   the cron tick is 30 minutes, so a 10-minute window is safely under
   the next-tick interval). On lock contention the run exits 0 with
   status `noop-locked`; the next tick retries.
2. **`git fetch origin`.** Refresh remote-tracking refs **before** the
   ahead/behind probe so the short-circuit in step 4 sees what origin
   actually has. Without this, a long-idle clean clone computes
   `behind` from whatever `origin/<branch>` happened to point at on
   the last successful fetch — which can be days stale, and the
   noop short-circuit then traps the clone on stale state forever.
   Fetch failure (network / auth) is logged but **not blocking**: the
   sync continues on stale refs for this tick, which still allows
   local-only commits to be made and queued for push; the next tick
   retries. Skipped under `--dry-run` to preserve the "no git commands
   spawned" contract.
3. **Examine state.** `git status --porcelain` to detect a dirty working
   tree; `git rev-list --left-right --count HEAD...@{u}` to detect local
   commits ahead of (or behind) origin.
4. **Short-circuit if nothing to do.** Clean tree + 0 ahead + 0 behind
   ⇒ exit 0, status `noop-nothing-to-do`. (Accurate now that step 2
   refreshes the refs.)
5. **Compose commit body + compute commit scope.** Walk
   `.cache/*/logs/runs.jsonl` and `operator/extensions/tools/*/logs/runs.jsonl`
   for the runs-digest section of the commit body (see `runs-digest.mjs`).
   In parallel, compute the registry-scoped commit-scope decision (see
   below) — cheap, at most two small JSON reads.
6. **Stage — registry-scoped, never a blind `git add -A`.**

   When `<repoRoot>/operator/registry/{devices.json,repos.json}` both
   exist:
   - Resolve this device from `os.hostname()` against `devices.json`
     (exact host match, then device-id-equals-hostname, then
     short-name-before-first-dot — identical algorithm to
     `operator/extensions/tools/library-sync/run.mjs`'s
     `resolveDeviceId`, so the two tools never disagree about "which
     device am I").
   - Resolve the repos this device is entitled to
     (`devices.json[deviceId].entitlements.repos`).
   - Derive the EXCLUSION set: every entitled repo's mount `to` subtree,
     **unconditionally** (no ownership carve-out — see
     `registry-scope.mjs`'s module doc for why this is deliberately
     simpler than library-sync's own down-path owner-exclusion), plus
     `.vault/**` unconditionally.
   - Stage ONLY the in-scope dirty paths (everything from
     `git status --porcelain` NOT under an excluded subtree) via
     explicit pathspecs: `git add -- <path> <path> ...`. An untracked
     file inside an excluded subtree is never passed to `git add`, so it
     can never be staged — the pathspec approach handles this by
     construction, not by a follow-up filter.

   When the registry is **not present** at this repo root: previous
   (pre-rewrite) behaviour — unscoped `git add -A` — but LOUDLY flagged,
   both in the run's stderr output and in the commit body
   (`estate-sync: no registry found, unscoped commit`).

   Either way, the commit body gets a scope-decision section reporting
   counts: paths staged, subtrees excluded by rule (see "Commit body"
   below).
7. **`git commit -m "estate-sync <ISO ts>" -m "<body>"`.** Commit
   **before** the rebase. `git pull --rebase` refuses to run when the
   index or working tree has uncommitted changes, so the working state
   must be turned into a real commit first. Skipped if the tree is
   clean (e.g. a `behind`-only state — the pull is the whole job), or if
   every dirty path was excluded by the commit scope (nothing staged).
8. **`git pull --rebase origin <branch>`.** Fetch was done in step 2,
   so this is just the rebase. Now safe because the index is clean
   (we either committed in step 6/7 or the tree was already clean).
   Skipped entirely if step 2's fetch failed (rebasing onto stale
   refs is a no-op at best and a spurious failure at worst).

   **Conflict handling (auto-resolve on regenerated artifacts).** When
   the rebase conflicts, sync checks whether every conflicted path is
   in the *regenerated-artifacts allowlist* (see below). Files in that
   list are regenerated by distillation on every host — different byte
   output, semantically-equivalent content — so a conflict is a race
   between two hosts' regenerations, not a real disagreement. In that
   case sync takes origin's version (`git checkout --ours -- <paths>`;
   note the rebase-inversion — `--ours` refers to the branch being
   rebased *onto*), stages the paths, `git rebase --continue`s, and
   proceeds to push. The audit record adds
   `autoResolved: <N>` + `autoResolvedPaths: [...]` to the `ok` line.

   If **any** conflicted path is outside the allowlist, the tool
   attempts **self-heal on rebase-abort** (R2, w-2026-08-12-004, replaces
   the pre-R2 "abort and wedge" behaviour): estate-sync's own version of
   each conflicting path is queued to
   `<repoRoot>/.cache/estate-sync/pending/<pathSlug>__<firstSeenTs>` with
   a JSON sidecar carrying `originalPath`, `conflictingUpstreamCommitSha`,
   `upstreamBlobShaAtQueue`, `attemptCount`, `firstSeen`, and
   `alertLevel`. The pending content is fsynced BEFORE any git mutation
   runs, so a queue failure never triggers a reset. Then four cascading
   guards fire (HEAD sha equals the sha recorded immediately after
   `git commit`; HEAD^ exists; exactly one commit in `HEAD^..HEAD`;
   exactly one commit ahead of `origin/<branch>`); on any guard failure
   the tool falls through to the classic `fail-rebase` behaviour and
   stays wedged loudly. When all guards pass, `git reset --soft HEAD^`
   drops estate-sync's own commit while leaving BOTH the index and the
   working tree untouched — every non-conflicting path in the dropped
   commit stays staged and its bytes stay on disk for the next tick to
   recommit. Then surgically per conflicting path: newly-added paths get
   `git rm --cached -- <path>` + `fs.unlink` (unstaged and removed from
   disk; their bytes live in pending); modified paths get
   `git checkout HEAD -- <path>` (restored to the parent's version in
   both index and working tree). The audit record uses one of
   `fail-rebase-queued` (alertLevel `warn`, attempts < 5),
   `fail-rebase-stuck` (alertLevel `error`, attempts >= 5), or
   `fail-rebase-needs-human` (divergence detected during drain) with
   exit code **0** — the process exits healthy because the workspace
   itself is fast-forwardable again, while the `fail-*` status routes
   through librarian-alerts (when wired to estate-sync's runs.jsonl).

   > **Why `--soft` and not `--keep`** (correction landed 2026-08-12 via
   > the PR-120 fresh-context review): `--keep <ref>` updates the
   > working tree to match `<ref>` on every file that differs between
   > HEAD and `<ref>`. In a multi-file estate-sync commit (which is the
   > design — step 6 scoops every in-scope dirty path), that means
   > every non-conflicting path in the dropped commit would be silently
   > destroyed (newly-added files deleted, modifications reverted).
   > `--soft` leaves the index and working tree entirely alone; the
   > surgical scope above touches only the conflicting paths, so
   > non-conflicting content survives across the drop.

   On every subsequent tick, **DRAIN** runs at the top (inside the PID
   lock, after fetch so `origin/<branch>` refs are fresh), oldest-first.
   For each pending entry the tool compares the sidecar's
   `upstreamBlobShaAtQueue` against origin's blob NOW and picks one of
   three actions:
   - **`already-delivered`** — upstream's current blob bytes exactly
     match the pending content (another writer pushed our version for
     us). Pending removed cleanly, no workspace write.
   - **`staged-for-retry`** — upstream blob is unchanged since queue
     time. Copy the pending bytes back into the workspace at
     `originalPath`; step 6 will stage them and step 7 will rebase
     normally. Pending removed ONLY after commit + push are confirmed
     on HEAD (`finaliseDrainOnPushSuccess`); a crash between drain
     and commit leaves the pending file intact so the next tick
     re-evaluates.
   - **`divergence-held`** — upstream now has a DIFFERENT resolution
     than the sidecar recorded at queue time (a third writer intervened,
     a human reconciled with different bytes, etc.). The sidecar is
     stamped `alertLevel: 'needs-human'` (sticky — never de-escalates
     back to warn or error), and the workspace is NOT touched. This
     blocks the silent-clobber hazard the earlier ratified drain
     sketch had, where re-applying stale bytes would overwrite the
     winner's resolution.

   Pending queue lives at `.cache/estate-sync/pending/`, gitignored per
   the tooling standard §16. Content-safety invariant: no reset ever
   runs when the queue write fails, and no workspace write ever happens
   when upstream has diverged.

### Registry-scoped commit set

Source: `src/sync/registry-scope.mjs`. Pure functions over injected
registry docs + `os.hostname()` — no git, no spawning, fully unit-tested
in isolation from `run.mjs`.

Degrade-gracefully-never-crash contract, in priority order:

1. `operator/registry/devices.json` or `repos.json` missing at this repo
   root → `registryPresent: false` (unscoped fallback).
2. Either file present but unparseable JSON → `registryPresent: false`,
   with a warning naming the parse failure (unscoped fallback).
3. Registry present and parseable, but this device's hostname doesn't
   resolve to a known device id → `registryPresent: false`, warning
   naming the hostname tried (unscoped fallback).
4. Otherwise → `registryPresent: true`, scoped exclusion set computed.

None of these paths ever throws out of `computeCommitScope` — a broken
or absent registry degrades to the pre-rewrite behaviour, never a crashed
30-minute cron.

### Regenerated-artifacts allowlist

Built-in defaults (in `src/sync/regenerated-artifacts.mjs`):

- `operator/knowledge/.graph.json`
- `operator/knowledge/.backlinks.json`
- `operator/knowledge/domains.json`
- `operator/profile/profile.md`
- `operator/profile/operator.json`
- `operator/knowledge/systems/web-properties/*.md`

Repos can extend the allowlist by adding `<repoRoot>/.sync-config.json`:

```json
{
  "regeneratedArtifacts": [
    "projects/foo/generated.json",
    "some/tool/output/**/*.json"
  ]
}
```

Entries are additive to the defaults. Globs use POSIX-style `*`
(single segment) and `**` (any number of segments) via
`node:path.matchesGlob`. Missing / malformed config file falls back to
defaults with a stderr warning; the config is intentionally non-fatal
so an editor mistake can't wedge the cron.

9. **`git push`.** On rejection: `git fetch && git pull --rebase` and
   retry the push once. If the retry fails, exit 2 with status
   `fail-push` and leave the commit local (next tick will reconcile or
   surface again).
10. **Append audit record** to `<repoRoot>/.cache/estate-sync/logs/runs.jsonl`.

### `estate-sync run --dry-run`

Same as steps 1–5 (lock is still taken — dry-run still serialises
against itself), then prints the would-be commit title, body, and scope
decision, and exits 0. Never spawns `git add`/`commit`/`push`, never
writes `runs.jsonl`.

### Commit body

Two sections, in order:

1. **Scope-decision section** — either:
   - `estate-sync scope: registry-scoped (device=<id>)` with `staged: N
     path(s)` and `excluded: M subtree(s) by rule (K dirty path(s)
     matched) — <list>`, or
   - `estate-sync: no registry found, unscoped commit` (the loud
     unscoped-fallback flag).
2. **Runs-digest section** — the pre-existing per-cron-tool summary
   (unchanged; see `runs-digest.mjs`).

### `runs.jsonl` audit-record schema

One JSON object per line, camelCase keys (§11 of the tooling standard):

| Field | Type | Description |
|---|---|---|
| `ts` | string | RFC-3339 / ISO-8601 UTC timestamp. Round-trips through `new Date()`. |
| `status` | string | Semantic status, closed vocabulary (see below). |
| `exitCode` | number | Process exit code (0 / 2 / 3). |
| `durationSec` | number | Seconds elapsed since start of run. |
| `reason` | string | Present on every non-`ok`/non-`noop-nothing-to-do` record; omitted otherwise. |
| `dirty` | bool | Working tree was dirty at examine-time. `noop-nothing-to-do` only. |
| `ahead` | number | Local commits ahead of origin. `noop-nothing-to-do` only. |
| `behind` | number | Origin commits the local branch is behind. `noop-nothing-to-do` only. |
| `committed` | bool | A commit was created this run. `ok` only. |
| `pushed` | bool | A push succeeded this run. `ok` only. |
| `retriedAfterRebase` | bool | The push needed a rebase + retry. `ok` only. |
| `branch` | string | The current branch name. Present on `ok` and `noop-nothing-to-do`. |
| `autoResolved` | number | Number of allowlisted regenerated-artifact paths auto-resolved during a rebase conflict. `ok` only, present iff > 0. |
| `autoResolvedPaths` | string[] | The specific paths that got the `--ours` treatment. `ok` only, paired with `autoResolved`. |
| `conflictedPaths` | string[] | The disallowed conflicted paths. Present on `fail-rebase` (self-heal declined, wedged loud), and on any `fail-rebase-queued` / `fail-rebase-stuck` / `fail-rebase-needs-human` (self-heal ran, workspace healthy again). |
| `selfHealed` | bool | Set to `true` on any self-heal audit record (`fail-rebase-queued`, `-stuck`, or `-needs-human`). |
| `droppedCommit` | string | 40-hex sha of estate-sync's own commit that self-heal dropped. Present on all self-heal records. |
| `queued` | object[] | Summary of each queued pending entry (id, originalPath, attemptCount, alertLevel, upstreamBlobShaAtQueue, conflictingUpstreamCommitSha, pendingPath). Present on all self-heal records. |
| `drained` | object[] | Summary of drain results for this tick (id, originalPath, action, attemptCount, optional alertLevel + upstream blob shas). Present on any tick that saw pending entries at start-of-tick. |
| `drainFinalised` | string[] | Ids of pending entries removed after commit + push were confirmed on HEAD. Present on `ok` records that carried `drained` entries with action `staged-for-retry`. |
| `selfHealDeclined` | object | `{ stage, reason }` when self-heal was invoked but refused (queue-write failure, guard failure, reset failure). Paired with `status: fail-rebase` and `exitCode: 2`. |

Status vocabulary (closed set):

| Status | exitCode | When |
|---|---|---|
| `ok` | 0 | Commit + push (or pull-only) succeeded. |
| `noop-nothing-to-do` | 0 | Clean tree, no local commits ahead, up-to-date with origin. |
| `noop-locked` | 0 | Another sync run holds the lock. |
| `fail-rebase` | 2 | `git pull --rebase` conflicted; self-heal either not attempted (regenerated-artifacts branch already resolved) or declined (guard-failure / queue-write-failure / reset-failure). Workspace is wedged, human reconciliation required. |
| `fail-rebase-queued` | 0 | Self-heal ran on a non-allowlist conflict, dropped estate-sync's own commit under guard, queued the conflicting content for retry. Workspace is fast-forwardable again; librarian-alerts should surface the queued path (`alertLevel: warn`, `attemptCount < 5`). |
| `fail-rebase-stuck` | 0 | Same as `fail-rebase-queued` but the pending entry's `attemptCount >= 5` — escalated alert (`alertLevel: error`). |
| `fail-rebase-needs-human` | 0 | Self-heal ran, drain-time upstream divergence detected on a pending entry — someone else pushed a different resolution while ours was queued. Workspace NOT touched; pending held indefinitely (`alertLevel: needs-human`, sticky). |
| `fail-push` | 2 | Push rejected even after one rebase-and-retry. Commit is local-only. |
| `fail-git` | 3 | A `git status`/`add`/`commit` command itself errored (transport or IO). |
| `fail-cli-bootstrap` | varies | parseArgs / import / startup failure before runSync was reached. |

Consumer contract:
- **Process health** — check `exitCode`. `0` = the sync process itself is
  healthy (nothing to fix at cron / systemd level). This is the correct
  signal for "is the cron process wedged?"
- **Alert routing** — check `status.startsWith('fail-')`. Every `fail-*`
  status is a human-visible alert regardless of exitCode, because
  self-heal statuses (`fail-rebase-queued`, `fail-rebase-stuck`,
  `fail-rebase-needs-human`) intentionally exit 0 (workspace is healthy,
  process is healthy) while surfacing that a pending entry needs
  attention. Escalation ladder inside self-heal: `queued` → `stuck` at
  attempt >= 5 → `needs-human` on drain-time divergence (sticky).

## Exit codes

| Code | Meaning |
|---|---|
| `0` | OK — nothing to do, or sync completed and (optionally) pushed. |
| `1` | Usage error (unknown verb, bad flag). |
| `2` | Logical / operational failure (rebase conflict, push rejected after retry). |
| `3` | Transport / IO failure (a git command itself errored). |

## Operational discipline rules

1. **Single writer per host, for the paths it owns.** This tool is the
   only HQ process that performs `git add` / `git commit` / `git push`
   on the shared estate repo. It is scoped to estate-native content —
   mount-owned paths, `agents/*` run-state, and `.vault/**` ride the
   library pipeline instead (see the library-sync-solidification
   architecture decision, D1/D2). Cron tools (`distill`, `wsd-ingest`,
   `upstream-triage`) write files only. The chokepoint is the lock file
   plus the discipline that no other tool spawns mutating git commands.
2. **Never a blind `git add -A` when a registry is present.** Explicit
   pathspecs only. The no-registry fallback IS still `git add -A`, by
   design (nothing to scope against) — but it is loud about it in both
   the run's output and the commit body, never silent.
3. **No shell interpolation.** Every spawn passes arguments as an array
   with `shell:false`. Commit message bodies (which can contain newlines
   and quotes from runs.jsonl content) are passed through argv `-m`
   without any escaping required.
4. **Chokepoint for every external effect.** `src/sync/runner.mjs` is the
   only module that spawns processes or reads wall-clock time. Tests
   replace it with a scripted fake. `registry-scope.mjs` is pure (file
   reads + string logic only, no spawning) and independently testable.
5. **Lock-on-write.** The PID lock ensures two sync runs on the same
   host never both reach the `git commit` step. The lock is stale-safe
   (10 minutes) so a crashed runner does not wedge subsequent ticks.
6. **No stranded commits.** If push fails even after one rebase-retry,
   exit 2 (`fail-push`). The next 30m tick will retry from the start —
   discovering the local-only commit, pulling origin's new state,
   rebasing on top, pushing again. Monitor on `fail-push` runs lasting
   > 2 ticks.
7. **Degrade gracefully, never crash on registry state.** A missing,
   malformed, or device-unresolvable registry falls back to the
   pre-rewrite unscoped behaviour with a loud warning — never a thrown
   error, never a silently-scoped-wrong commit.

## Project layout

```
packages/cli/
  bin/
    estate-sync       Node shebang shim (canonical bin name). One line.
    sync              Same shim — back-compat alias, same entry point.
  src/sync/
    cli.mjs            Arg parsing (node:util.parseArgs), verb routing,
                        exit-code translation.
    run.mjs            Orchestrator: lock -> examine -> compute scope ->
                        stage -> commit -> pull --rebase -> push. One
                        function per step. (Commit precedes the rebase
                        because git pull --rebase refuses to run with
                        uncommitted changes.)
    registry-scope.mjs Registry-scoped commit-scope computation. Pure —
                        no spawning. Device resolution, mount-exclusion
                        derivation, porcelain-output filtering, scope
                        rendering for the commit body.
    git.mjs            Thin wrappers around the git commands sync invokes.
                        No spawning of its own — delegates to the runner.
    runner.mjs         Chokepoint. The only module that spawns processes
                        and reads the clock.
    lock.mjs           PID-file lock with stale-after-10m semantics.
                        Pure functions over an injectable fs.
    runs-digest.mjs    Discovers cron tool runs.jsonl files and tails the
                        last N records to compose a commit-body summary.
  test/sync/
    *.test.mjs         node:test.
```

Runtime-only (gitignored), under `<repoRoot>/.cache/estate-sync/`:
`.lock` (per-host PID file), `logs/runs.jsonl` (per-host audit roll-up),
`logs/<ts>.log` (per-run diagnostic logs, if added later).

## Tests

```
node --test packages/cli/test/sync/*.test.mjs
```

Tests use dependency injection over mocking libraries. Real git, real
filesystem mutations (outside of `tmpdir()`) and real network never
appear in the suite.

Coverage:

- `lock.test.mjs` — acquire on empty path, contention with a live PID,
  steal on a stale PID, steal on age-exceeded, release-only-if-still-ours.
- `runs-digest.test.mjs` — discovery (known parents, missing dirs
  tolerated, sync's own log excluded), tail (last N, malformed JSON
  lines skipped, empty file → empty array), body composition (header,
  per-tool sections, footer, edge cases).
- `git.test.mjs` — argv shape of every git wrapper call.
- `registry-scope.test.mjs` — device resolution (exact host, device-id
  match, short-name match, unresolvable), entitlement resolution (known
  device, unknown device, repo missing from repos.json), exclude-dir
  derivation (mount `to` union, `.vault/` always present, dedup + sort),
  porcelain-path parsing (plain entries, renames contribute both sides,
  short/malformed lines skipped), in-scope filtering (mount-owned dirty
  file excluded, foreign-agent run-state excluded, `.vault` change
  excluded, estate-native change included, untracked file inside an
  excluded subtree never staged), no-registry / malformed-registry /
  unknown-device fallback (all degrade to `registryPresent: false` with
  a warning, never throw), scope-section rendering (scoped and unscoped
  shapes).
- `run.test.mjs` — noop-nothing-to-do, noop-locked, dry-run no side
  effects (asserts fetch->add->commit->pull->push ordering in the plan
  output), dry-run on clean-but-behind tree omits add/commit, dirty
  happy path (asserts fetch precedes add, add precedes commit, commit
  precedes pull --rebase), regression coverage for the dirty + 0 ahead +
  0 behind case and the clean + remote-has-new-commits case, rebase
  conflict path (fail-rebase + audit + rebase --abort invoked), push
  retry succeeds, push retry fails (fail-push), fetch failure during
  pull tolerated, fetch failure during push-retry surfaces as fail-push,
  auto-resolve on allowlisted regenerated artifacts, still fail-rebase
  on any non-allowlisted conflict, mixed conflict fails without partial
  resolution, `.sync-config.json` override extends the allowlist,
  rebase --continue failure after resolution still fail-rebases,
  **registry-scoped staging** (mount-owned dirty file not committed,
  foreign-agent run-state not committed, `.vault` change not committed,
  estate-native change is committed, no-registry fallback warns +
  commits all, untracked file inside excluded subtree not staged, all
  dirty paths excluded → no commit attempted), commit title/body reflect
  `estate-sync` naming and the scope-decision section.
- `regenerated-artifacts.test.mjs` — defaults pin the known distill
  outputs, glob semantics (exact / `*` single-segment / `**` deep),
  classifyConflicts partitioning, config loader edge cases (missing
  file / malformed JSON / non-array field / non-string entries).
- `cli.test.mjs` — `--help`, `-h`, no-args, unknown verb, unknown flag,
  for both `estate-sync` and `sync` bins (identical behaviour).

## The tooling standard

This tool follows [`packages/core/docs/standards/tooling.md`](../../core/docs/standards/tooling.md)
(§1 identity, §2 Node-only, §3 zero deps, §4 structure, §5 CLI,
§6 exit codes, §7 chokepoint, §8 testing, §11 camelCase JSON,
§15 audit-record, §16 gitignored operational artefacts).

## Relationship to other tools

- **distill** writes the staging→knowledge promotion outputs. It does
  not commit or push — sync picks those up on its next tick (if the
  paths it wrote to are in-scope; distill's own outputs under
  `operator/knowledge/` are canonical-owned mount content on most
  estates and land via the library pipeline, not this tool).
- **library-sync** (`operator/extensions/tools/library-sync/`) owns
  ALL mount-owned content, including `agents/*` run-state — this is
  exactly the set estate-sync now excludes by construction.
- **wsd-ingest** writes per-entry markdown into `agents/dave/staging/`.
  Under the current registry, `agents/dave/` is a mount `to` path (the
  `stravica-library-hq` repo's `agents/barry/dave/` mount) and is
  therefore excluded from estate-sync's commit scope — that content
  reaches canonical exclusively via the library up-path, never via this
  tool's git commit.

Cross-host visibility latency: estate-native outputs written by a cron
tool reach origin at the next sync tick (max 30 minutes behind
real-time). Mount-owned content's visibility latency is governed by the
library pipeline's own cadence instead.
