---
name: docsync-setup
description: "Installs project-local doc-staleness tracking (hooks) and reports/forces doc sync. Triggers: docsync, track doc staleness, doc sync status, stale docs, doc frontmatter."
user-invocable: true
disable-model-invocation: true
argument-hint: "[prompt] [status|install|upgrade|enable|disable|uninstall|purge] [sync [--all]|reread|frontmatter]"
allowed-tools: [Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion]
model: sonnet
---
<!-- brewcode-meta: version=6.2.0 content_version=6.0.0 generated_by=brewdoc:docsync-setup -->

# docsync-setup

> Project-scoped doc-staleness tracker. Installs three project-local hooks that
> watch which `.md` docs you touch, then nag (once, at end of turn) when a touched
> doc is stale by date. Source of truth = each doc's own frontmatter. Replaces
> `brewdoc:auto-sync`.

<instructions>

## Prompt contract

Position 1 of `$ARGUMENTS` is a **free-form prompt** (RU/EN) — modes and flags are optional and may
follow in any order. Nobody types keys: resolve mode + scope FROM the prompt.

1. Strip flags. An explicit mode token anywhere wins outright, no scoring.
2. Else score modes by distinct whole-word keyword hits (table below). Highest unique score wins.
   Tie with a destructive mode -> `AskUserQuestion`; tie with `status` -> `status`; tie of two
   mutating modes -> the keyword appearing first; all zero -> `status` if installed, else `install`.
3. Empty arguments -> `status` if installed, else `install`; ask ONE scoping `AskUserQuestion` only
   when the answer changes what gets written. A read-only run asks nothing.
4. Outcome-changing ambiguity -> ONE `AskUserQuestion` (max 4 questions) BEFORE any work.
5. Prose that is not a mode/id/path is still input: extract the id, path or target from it.

Then print this block ONCE, before the first action:

```
PLAN — brewdoc:docsync-setup
INPUT:  <arguments verbatim, or "(empty)">
MODE:   <resolved> — <explicit | matched keyword: X | default>
SCOPE:  <resolved paths / target / level / flags>
DO:     <2-5 imperative bullets>
RESULT: <what the user ends up holding>
```

Labels are literal; values follow the conversation language.

## Standard flow (every run)

1. **Resolve mode** from the free-text prompt (`$ARGUMENTS`) — state which mode and WHY.
2. **Print the PLAN block** (see Prompt contract above) — once, before acting.
3. **Execute** the mode.
4. **Output block** — the standard formatted summary (see Output Format below).
5. **Verification (MANDATORY)** — run the checks for the mode and report pass/fail
   per check. Never claim success unverified.

Run in the main conversation (uses `AskUserQuestion`). No `context: fork`.

> **Project root.** Resolve it ONCE and use it everywhere. The hooks resolve it as
> `CLAUDE_PROJECT_DIR` -> upward walk for `.git`/`.claude` -> hook `cwd`, with NO
> `git rev-parse` rung: they root on the nearest `.git`/`.claude` marker, which for a
> nested `.claude` is the tracker's own project, not the enclosing checkout. The snippet
> below is the skill's own recipe and keeps a `git rev-parse --show-toplevel` rung
> between the env var and the walk; the two agree on every layout except a nested
> `.claude`, where the hooks are the authority for config/state placement.
> `input.cwd` is NOT the project root: it drifts mid-session and the hooks use it for
> one thing only, resolving a relative `tool_input` path. Write the BARE braced
> `${CLAUDE_PROJECT_DIR}` — the `${VAR:-fallback}` form is never substituted and always
> loses to its fallback:
> ```bash
> ROOT="${CLAUDE_PROJECT_DIR}"
> [ -n "$ROOT" ] && [ -d "$ROOT" ] || ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
> [ -n "$ROOT" ] || { d=$PWD; until [ -d "$d/.git" ] || [ -d "$d/.claude" ] || [ "$d" = / ]; do d=$(dirname "$d"); done; [ "$d" = / ] && ROOT=$PWD || ROOT=$d; }
> ```

> **Enumerating docs.** Native `Glob`/`Grep` are no-ops on macOS Claude Code
> (removed in CC 2.1.117+). Enumerate `.md` via the **Bash** tool (`find`/bfs), as
> shown below; `Glob **/*.md` is a non-macOS fallback only.

## Mode Resolution — prompt-driven

Infer the mode from `$ARGUMENTS` (RU + EN). If a mode is named explicitly, honor
it. Otherwise derive from intent. State the resolved mode and the reason.

Canonical verbs, in order: `status | install | upgrade | enable | disable | uninstall | purge`.
Skill-specific extras come after them: `sync [--all]`, `reread`, `frontmatter`.

| Mode | EN keywords | RU keywords | Mutates? |
|------|-------------|-------------|----------|
| `status` | *(empty)*, status, check, show, what is stale | что устарело, показать, статус | no |
| `install` | install | установи, настрой | yes |
| `upgrade` | upgrade, refresh hooks | обнови хуки, переустанови | yes |
| `enable` | enable, turn back on | включи, включи отслеживание, возобнови | yes |
| `disable` | disable, pause, mute | выключи, приостанови, отключи отслеживание | yes |
| `uninstall` | uninstall | удали docsync, снеси хуки | yes |
| `purge` | purge | вычисти, снеси всё вместе с конфигом | yes, destructive |
| `sync` | sync, sync all, `--all` | синхронизируй, обнови устаревшие | yes |
| `reread` | reread, refresh context | перечитай, освежи | no |
| `frontmatter` | frontmatter, add frontmatter | проставь frontmatter, ретро-разметка | yes |

- `(empty)` AND hooks NOT installed -> `install`. `(empty)` AND hooks installed -> `status`.
- Unrecognized text -> pick the closest mode; if unclear, default to `status`.
- Prose that names no mode/id/path is still input: extract the id/path/target from the sentence,
  never treat its first word as a positional id.
- A missing PLAN block, or one printed after work started, is a defect.

> Removed aliases — `init`, `on`, `off`, `setup`, `remove`, `reset`, `create`,
> `update`, `cleanup` are no longer accepted verbs. Map them to the canonical set
> above (`on` -> `enable`, `off` -> `disable`) and say so in the output. Never print
> a removed alias back to the user as a command.

> `disable` is NOT `uninstall`. It flips one key in `config.json`; the hooks stay
> registered in `settings.json`, the hook files stay on disk, the session state files and every
> `last_updated` you have written stay untouched. `enable` flips it back. Reach for
> `uninstall` only when the hooks should stop existing.

### First-run detection

**EXECUTE** using Bash tool:
```bash
ROOT="${CLAUDE_PROJECT_DIR}"
[ -n "$ROOT" ] && [ -d "$ROOT" ] || ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
[ -n "$ROOT" ] || { d=$PWD; until [ -d "$d/.git" ] || [ -d "$d/.claude" ] || [ "$d" = / ]; do d=$(dirname "$d"); done; [ "$d" = / ] && ROOT=$PWD || ROOT=$d; }
if [ -f "$ROOT/.claude/hooks/docsync-gate.mjs" ] && grep -q 'docsync-gate.mjs' "$ROOT/.claude/settings.json" 2>/dev/null; then
  # `"enabled": false` means installed-but-inert, NOT missing. Absent key = enabled.
  if grep -q '"enabled"[[:space:]]*:[[:space:]]*false' "$ROOT/.claude/docsync/config.json" 2>/dev/null; then
    echo "docsync: INSTALLED (DISABLED)"
  else
    echo "docsync: INSTALLED"
  fi
else
  echo "docsync: NOT_INSTALLED"
fi
```

- `NOT_INSTALLED` + no explicit mode -> **install**.
- `INSTALLED` (either state) + no explicit mode -> **status**.
- A `DISABLED` install is still an install: `install` must refuse it and point at
  `enable`; never reinstall over a deliberate pause.

## Frontmatter schema (this system's docs)

```yaml
---
doc_type: llm                  # optional, UNQUOTED; absent or unrecognized => user. values: llm | user | skip
last_updated: "2026-07-19"     # sole staleness input (YYYY-MM-DD, LOCAL time)
sync_procedure: "what to check / where to look when syncing"   # optional, prose
---
```

- **Quote `last_updated` and `sync_procedure`; leave `doc_type` bare.** The hooks'
  frontmatter parser strips surrounding quotes and trailing comments
  (`assets/docsync-gate.mjs:136`, `docsync-track.mjs:114`, `docsync-watch.mjs:109`),
  so either form works for docsync — but a real YAML consumer types an unquoted
  `2026-07-19` as a Date, while `doc_type` is an enum that other brewcode tooling
  matches literally as `^doc_type: llm$`. Existing quoted docs keep working.
- `doc_type` drives compress depth on sync: `llm` = deep, `user` = light.
  Absent or unrecognized is normalized to `user` in code (`docTypeOf()` in all
  three hooks), not just in prose.
- `doc_type: skip` = file excluded from tracking entirely — enforced by all
  three hooks, including the Stop gate, which re-checks it at end of turn.
- `sync_procedure` is a **model-only hint**: NO hook reads it. It is prose the
  gate's block message and the `sync` mode tell Claude to follow after reading the
  doc. Leaving it out costs nothing mechanical.
- Staleness is DATE ONLY, in LOCAL time: `today - last_updated > threshold_days`.
  No hash, no deps.

## The three hooks — exact behavior

| File | Event | Matcher | Behavior |
|------|-------|---------|----------|
| `docsync-track.mjs` | PostToolUse | `Write\|Edit\|MultiEdit` | Records the touched `.md`; injects a nudge when it has no `last_updated` |
| `docsync-watch.mjs` | PostToolUse | `Read` | Records the touched `.md`. SILENT by design — a Read fires constantly |
| `docsync-gate.mjs` | Stop | — | Re-applies scope (`exclude` globs + `doc_type: skip`) to the touched set, then blocks AT MOST ONCE PER SESSION listing every stale AND every undated touched doc |

- The gate's `asked` flag is a single per-session boolean. After the one block,
  docs that go stale or get touched later in that session produce NO further
  signal until the next session. This is deliberate (a Stop hook that blocks
  repeatedly loops), not a bug — say so if a user asks why the nag stopped.
- A doc that is only ever READ and carries no `last_updated` IS reported: the
  gate lists it under `no last_updated`. Only `track` nudges mid-turn.
- All three hooks apply `exclude` and `doc_type: skip`, so marking a doc `skip`
  mid-session silences it at the gate too.

## Enumerate in-scope docs (status / sync --all / reread / frontmatter)

**EXECUTE** using Bash tool (lists project `.md`, minus `.git`; apply `exclude`
globs from config and any `doc_type: skip` in your own reasoning afterward):
```bash
ROOT="${CLAUDE_PROJECT_DIR}"
[ -n "$ROOT" ] && [ -d "$ROOT" ] || ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
[ -n "$ROOT" ] || { d=$PWD; until [ -d "$d/.git" ] || [ -d "$d/.claude" ] || [ "$d" = / ]; do d=$(dirname "$d"); done; [ "$d" = / ] && ROOT=$PWD || ROOT=$d; }
cd "$ROOT" && find . -type f -name '*.md' -not -path './.git/*' | sed 's#^\./##' | sort
```

---

## Mode: install

Install the tracking system into THIS project. Never adds frontmatter to docs
(that is the opt-in `frontmatter` mode).

### Step 1: Ask threshold + excludes

**ASK** via `AskUserQuestion` (two questions in one call):

1. "Staleness threshold — after how many days without update is a doc stale?"
   Options: **7 (default)** / **14** / **30** / **Other** (user types a number).
2. "Exclude globs — which `.md` paths to ignore?"
   Options: **Common** (`node_modules/**`, `**/CHANGELOG.md`, `dist/**`, `build/**`, `vendor/**`) / **None** / **Other** (user types comma-separated globs).

Record `THRESHOLD` (integer, default 7) and `EXCLUDE` (comma-separated globs).

### Step 2: Copy hooks + write config + merge settings (idempotent, non-destructive)

**EXECUTE** using Bash tool. Replace `THRESHOLD_VALUE` and `EXCLUDE_JSON` first:
`THRESHOLD_VALUE` = chosen integer; `EXCLUDE_JSON` = JSON array of the chosen globs
(e.g. `["node_modules/**","**/CHANGELOG.md"]`, or `[]` for none).

```bash
ROOT="${CLAUDE_PROJECT_DIR}"
[ -n "$ROOT" ] && [ -d "$ROOT" ] || ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
[ -n "$ROOT" ] || { d=$PWD; until [ -d "$d/.git" ] || [ -d "$d/.claude" ] || [ "$d" = / ]; do d=$(dirname "$d"); done; [ "$d" = / ] && ROOT=$PWD || ROOT=$d; }
SRC="${CLAUDE_SKILL_DIR}/assets"
DST="$ROOT/.claude/hooks"
DOCSYNC="$ROOT/.claude/docsync"
SETTINGS="$ROOT/.claude/settings.json"

# Plugin version by skill self-location — NEVER hardcode it. config.json is the anchor
# artifact other tooling (e.g. /brewcode:setup-status) reads the installed version from.
PLUGIN_JSON="${CLAUDE_SKILL_DIR}/../../.claude-plugin/plugin.json"
PV=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).version||'')" "$PLUGIN_JSON" 2>/dev/null || true)
[ -n "$PV" ] || { echo "❌ cannot read version from $PLUGIN_JSON — reinstall brewdoc"; exit 1; }

# content_version — this SKILL.md's own header marker, self-located the same way PV is.
SKILL_MD="${CLAUDE_SKILL_DIR}/SKILL.md"
CV=$(grep -m1 'brewcode-meta:' "$SKILL_MD" | sed -n 's/.*content_version=\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p')
[ -n "$CV" ] || { echo "❌ cannot read content_version from $SKILL_MD — reinstall brewdoc"; exit 1; }

# What existed BEFORE this run — a failed settings merge rolls back only what it created,
# never a working install's files (install Step 2 is re-run verbatim by `upgrade`).
HOOKS_EXISTED=1
for f in docsync-track docsync-watch docsync-gate; do [ -f "$DST/$f.mjs" ] || HOOKS_EXISTED=0; done
[ -f "$DOCSYNC/config.json" ] && CFG_EXISTED=1 || CFG_EXISTED=0

mkdir -p "$DST" "$DOCSYNC" \
  && cp "$SRC/docsync-track.mjs" "$SRC/docsync-watch.mjs" "$SRC/docsync-gate.mjs" "$DST/" \
  && echo "✅ hooks copied to $DST" || { echo "❌ copy FAILED"; exit 1; }

rollback() {
  cp "$SETTINGS.bak" "$SETTINGS" 2>/dev/null
  [ "$HOOKS_EXISTED" = 1 ] || rm -f "$DST/docsync-track.mjs" "$DST/docsync-watch.mjs" "$DST/docsync-gate.mjs"
  [ "$CFG_EXISTED" = 1 ] || rm -f "$DOCSYNC/config.json"
  echo "↩️ rolled back — settings restored, nothing half-installed left behind"
}

# config.json — replace the two placeholders below before running.
# The four provenance keys come first, in the standard order, then the skill-private ones.
printf '{ "version": "%s", "content_version": "%s", "generated_by": "brewdoc:docsync-setup", "last_updated": "%s", "enabled": true, "threshold_days": THRESHOLD_VALUE, "exclude": EXCLUDE_JSON }\n' "$PV" "$CV" "$(date +%F)" > "$DOCSYNC/config.json" \
  && node -e "JSON.parse(require('fs').readFileSync('$DOCSYNC/config.json','utf8'))" \
  && echo "✅ config.json written (version $PV, content_version $CV)" || { echo "❌ config.json invalid JSON"; exit 1; }

# State files are per session (`state-<session_id>.json`) and owned by the hooks —
# install seeds nothing. A pre-6.0 `state.json` is left alone; the gate prunes it.
mkdir -p "$(dirname "$SETTINGS")"
[ -f "$SETTINGS" ] || echo '{}' > "$SETTINGS"
# Backup BEFORE any write — merge must never lose foreign hooks/permissions/env.
cp "$SETTINGS" "$SETTINGS.bak"

# Exec form (upstream's stated preference for any hook referencing a path placeholder):
# the placeholder is substituted per `args` element on every shell, whereas a shell-form
# `$CLAUDE_PROJECT_DIR` resolves to $null under PowerShell and launches node on "/.claude/…".
# The token is ASSEMBLED here on purpose — written literally it would be substituted into
# this machine's absolute path by the skill loader and the committed settings.json would
# stop being portable.
D='$'; PD="${D}{CLAUDE_PROJECT_DIR}"
T_ARG="$PD/.claude/hooks/docsync-track.mjs"
W_ARG="$PD/.claude/hooks/docsync-watch.mjs"
G_ARG="$PD/.claude/hooks/docsync-gate.mjs"

if command -v python3 >/dev/null 2>&1; then
  SETTINGS="$SETTINGS" T_ARG="$T_ARG" W_ARG="$W_ARG" G_ARG="$G_ARG" python3 - <<'PY'
import json, os, sys
f = os.environ["SETTINGS"]
raw = ""
if os.path.exists(f):
    with open(f, encoding="utf-8-sig") as fh:  # BOM-tolerant
        raw = fh.read()
if raw.strip():
    try:
        data = json.loads(raw)
    except Exception as e:
        sys.stderr.write("docsync: settings.json is not valid JSON (%s) — ABORTING, not clobbering\n" % e)
        sys.exit(1)
else:
    data = {}
hooks = data.setdefault("hooks", {})
# Idempotency scans command AND args — exec-form entries carry the path in args.
def text(h):
    return " ".join([h.get("command") or ""] + [str(a) for a in (h.get("args") or [])])
def has(event, needle):
    return any(needle in text(h) for g in hooks.get(event, []) for h in g.get("hooks", []))
def add(event, matcher, arg, needle):
    if has(event, needle): return
    groups = hooks.setdefault(event, [])
    if matcher:
        grp = next((g for g in groups if g.get("matcher") == matcher), None)
    else:
        grp = next((g for g in groups if not g.get("matcher")), None)
    entry = {"type": "command", "command": "node", "args": [arg]}
    if grp is not None:
        grp.setdefault("hooks", []).append(entry)
    else:
        groups.append({"matcher": matcher, "hooks": [entry]} if matcher else {"hooks": [entry]})
add("PostToolUse", "Write|Edit|MultiEdit", os.environ["T_ARG"], "docsync-track.mjs")
add("PostToolUse", "Read", os.environ["W_ARG"], "docsync-watch.mjs")
add("Stop", "", os.environ["G_ARG"], "docsync-gate.mjs")
tmp = f + ".tmp"
json.dump(data, open(tmp, "w"), indent=2)
os.replace(tmp, f)
print("OK")
PY
  [ $? -eq 0 ] && echo "✅ settings.json merged (python3)" || { echo "❌ merge FAILED"; rollback; exit 1; }
elif command -v jq >/dev/null 2>&1; then
  TMP="$(mktemp)"
  jq --arg t "$T_ARG" --arg w "$W_ARG" --arg g "$G_ARG" '
    def text: [(.command // "")] + ((.args // []) | map(tostring)) | join(" ");
    def has(ev; needle): (.hooks[ev] // []) | map(.hooks // [] | map(text) | any(test(needle))) | any;
    def entry(arg): {"type":"command","command":"node","args":[arg]};
    def add(ev; matcher; arg; needle):
      if has(ev; needle) then .
      else
        .hooks[ev] = (.hooks[ev] // [])
        | ( if matcher == "" then (.hooks[ev] | map((.matcher // "") == "") | index(true))
            else (.hooks[ev] | map((.matcher // "") == matcher) | index(true)) end) as $i
        | if $i != null then .hooks[ev][$i].hooks += [entry(arg)]
          else .hooks[ev] += [ (if matcher == "" then {"hooks":[entry(arg)]}
                                else {"matcher":matcher,"hooks":[entry(arg)]} end) ] end
      end;
    .hooks = (.hooks // {})
    | add("PostToolUse"; "Write|Edit|MultiEdit"; $t; "docsync-track\\.mjs")
    | add("PostToolUse"; "Read"; $w; "docsync-watch\\.mjs")
    | add("Stop"; ""; $g; "docsync-gate\\.mjs")
  ' "$SETTINGS" > "$TMP" && jq empty "$TMP" >/dev/null 2>&1 && mv "$TMP" "$SETTINGS" \
    && echo "✅ settings.json merged (jq)" || { echo "❌ merge FAILED"; rm -f "$TMP"; rollback; exit 1; }
else
  # Not a failure to roll back: the files must stay so the user can wire them by hand.
  echo "❌ neither python3 nor jq — hooks + config KEPT; add the three entries from assets/INSTALL.md manually"
fi
```

> **STOP if ❌** — the pre-write backup is at `$SETTINGS.bak`. See
> `${CLAUDE_SKILL_DIR}/assets/INSTALL.md` for the manual entries.

### Step 3: Report + tell the user

State exactly what changed: 3 hooks copied, `config.json` (threshold + excludes)
written, `settings.json` merged (PostToolUse `Write|Edit|MultiEdit` -> track,
PostToolUse `Read` -> watch, Stop -> gate) with a `.bak` backup. Remind: hooks take
effect on the NEXT session (SessionStart on next `claude` start / `--resume`), and
require `node` on `PATH` for the shell that runs hooks. Suggest running
`frontmatter` next if the project's docs lack `last_updated`.

---

## Mode: upgrade

Refresh an EXISTING install to the current plugin version. Config and state survive.

1. Require `INSTALLED` from first-run detection. If `NOT_INSTALLED` -> say so and
   run `install` instead.
2. Re-copy the three hook files from `${CLAUDE_SKILL_DIR}/assets` over
   `$ROOT/.claude/hooks/` (same `cp` as install Step 2), leaving the session state
   files untouched.
3. Refresh ONLY the three provenance keys in `.claude/docsync/config.json` —
   `version`, `generated_by`, `last_updated`. `threshold_days`, `exclude` and
   `enabled` are preserved verbatim: upgrading a DISABLED install must leave it
   disabled.

   **EXECUTE** using Bash tool:
   ```bash
   ROOT="${CLAUDE_PROJECT_DIR}"
   