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.
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.
- Strip flags. An explicit mode token anywhere wins outright, no scoring.
- 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.
- 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.
- Outcome-changing ambiguity -> ONE
AskUserQuestion (max 4 questions) BEFORE any work.
- 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)
- Resolve mode from the free-text prompt (
$ARGUMENTS) — state which mode and WHY.
- Print the PLAN block (see Prompt contract above) — once, before acting.
- Execute the mode.
- Output block — the standard formatted summary (see Output Format below).
- 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:
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:
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)
---
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):
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):
- "Staleness threshold — after how many days without update is a doc stale?"
Options: 7 (default) / 14 / 30 / Other (user types a number).
- "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).
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.
Require INSTALLED from first-run detection. If NOT_INSTALLED -> say so and
run install instead.
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.
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:
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; }
C="$ROOT/.claude/docsync/config.json"
PJ="${CLAUDE_SKILL_DIR}/../../.claude-plugin/plugin.json"
SKILL_MD="${CLAUDE_SKILL_DIR}/SKILL.md"
node -e '
const fs = require("fs");
const [c, pj, today, skillMd] = process.argv.slice(1);
const v = JSON.parse(fs.readFileSync(pj, "utf8")).version;
if (!v) throw new Error("no version in " + pj);
const header = fs.readFileSync(skillMd, "utf8").split("\n").find(l => l.includes("brewcode-meta:")) || "";
const cvm = /content_version=([0-9]+\.[0-9]+\.[0-9]+)/.exec(header);
if (!cvm) throw new Error("no content_version in " + skillMd);
const cv = cvm[1];
const cfg = JSON.parse(fs.readFileSync(c, "utf8"));
const was = cfg.version || "(none)";
const { version, content_version, generated_by, last_updated, ...rest } = cfg;
const next = { version: v, content_version: cv, generated_by: "brewdoc:docsync-setup", last_updated: today, ...rest };
fs.writeFileSync(c, JSON.stringify(next, null, 2) + "\n");
console.log(`config.json version ${was} -> ${v}; content_version=${next.content_version}, generated_by=${next.generated_by}, last_updated=${next.last_updated}; enabled=${next.enabled !== false}, threshold_days=${next.threshold_days}, exclude=${JSON.stringify(next.exclude)}`);
' "$C" "$PJ" "$(date +%F)" "$SKILL_MD" && echo "✅ config provenance refreshed" || { echo "❌ config provenance refresh FAILED"; exit 1; }
Re-run the settings merge from install Step 2 — it is idempotent, so it only
restores entries a user or another tool dropped.
Run the install verification block and report per-check pass/fail, plus
threshold_days + exclude + enabled unchanged.
Mode: status
Report tracked docs and staleness. No changes.
- Read
$ROOT/.claude/docsync/config.json (threshold + excludes + enabled). If
missing -> "not installed; run install". If enabled is false, lead the report
with DISABLED — hooks are wired but inert; enable resumes them, then report
staleness anyway: the numbers stay meaningful while the tracker is paused.
- Enumerate in-scope docs via the Bash
find block above; drop exclude matches
and any with doc_type: skip.
- For each, read frontmatter
last_updated; compute age in days (LOCAL time);
mark stale when age > threshold_days; mark no-date when missing.
- Read
$ROOT/.claude/docsync/state-<session_id>.json (one file per session; a
pre-6.0 install may still carry a shared state.json) and report the current
session touched-set.
- Output the Status table (below).
Mode: enable / disable
Flip docsync between live and inert WITHOUT unwiring anything. One key,
"enabled", in .claude/docsync/config.json:
|
hooks in settings.json |
hook files |
config.json |
session state |
doc frontmatter |
disable |
kept |
kept |
enabled: false + provenance refreshed |
kept |
untouched |
enable |
kept |
kept |
enabled: true + provenance refreshed |
kept |
untouched |
uninstall |
removed |
removed |
kept |
kept |
untouched |
purge |
removed |
removed |
deleted |
deleted |
untouched |
All three hooks read enabled on every invocation (loadConfig, absent = true),
so the flip takes effect IMMEDIATELY — no session restart, unlike install/uninstall
which change settings.json. Disabled means: no touched-set recording, no
frontmatter nudge, and the Stop gate never blocks.
- Require
INSTALLED (either state) from first-run detection. NOT_INSTALLED ->
say so and offer install; do not write a config for hooks that do not exist.
- Read the current value. Short-circuit ONLY when it already matches the requested
verb AND the three provenance keys are current (
version == plugin version,
generated_by == brewdoc:docsync-setup, last_updated present) — report
already enabled / already disabled and stop, nothing written. A config whose
value already matches but whose provenance is missing or stale IS rewritten: every
mode that writes this file stamps it, so a pre-standard config gets backfilled here
instead of staying unstamped forever.
- EXECUTE using Bash tool (
WANT = true for enable, false for disable):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; }
C="$ROOT/.claude/docsync/config.json"
PJ="${CLAUDE_SKILL_DIR}/../../.claude-plugin/plugin.json"
SKILL_MD="${CLAUDE_SKILL_DIR}/SKILL.md"
WANT=true # <- set to false for `disable`
[ -f "$C" ] || { echo "❌ $C missing — docsync is not installed"; exit 1; }
cp "$C" "$C.bak"
C="$C" WANT="$WANT" PJ="$PJ" SKILL_MD="$SKILL_MD" TODAY="$(date +%F)" node -e '
const fs = require("fs");
const c = process.env.C, want = process.env.WANT === "true";
const v = JSON.parse(fs.readFileSync(process.env.PJ, "utf8")).version;
if (!v) throw new Error("no version in " + process.env.PJ);
const header = fs.readFileSync(process.env.SKILL_MD, "utf8").split("\n").find(l => l.includes("brewcode-meta:")) || "";
const cvm = /content_version=([0-9]+\.[0-9]+\.[0-9]+)/.exec(header);
if (!cvm) throw new Error("no content_version in " + process.env.SKILL_MD);
const cv = cvm[1];
const cfg = JSON.parse(fs.readFileSync(c, "utf8"));
const was = cfg.enabled !== false;
const stamped = cfg.version === v && cfg.content_version === cv && cfg.generated_by === "brewdoc:docsync-setup" && /^\d{4}-\d{2}-\d{2}$/.test(cfg.last_updated || "");
if (was === want && stamped) { console.log(`already ${want ? "enabled" : "disabled"}, provenance current — nothing written`); process.exit(0); }
cfg.enabled = want;
const { version, content_version, generated_by, last_updated, ...rest } = cfg;
const next = { version: v, content_version: cv, generated_by: "brewdoc:docsync-setup", last_updated: process.env.TODAY, ...rest };
fs.writeFileSync(c, JSON.stringify(next, null, 2) + "\n");
console.log(`enabled: ${was} -> ${want}; version=${next.version}, content_version=${next.content_version}, generated_by=${next.generated_by}, last_updated=${next.last_updated}; threshold_days=${next.threshold_days}, exclude=${JSON.stringify(next.exclude)} (preserved)`);
' && echo "✅ done" || { echo "❌ FAILED"; exit 1; }
STOP if ❌ — fix before continuing.
- Verify:
config.json is still valid JSON, enabled holds the requested value,
threshold_days + exclude are byte-unchanged, and the three provenance keys are
present and current (version == plugin version, generated_by ==
brewdoc:docsync-setup, last_updated == today).
- Report the new state and its reversal verb. After
disable, say the hooks are
still registered and enable brings them back with zero re-analysis.
Mode: uninstall
Remove docsync from THIS project without touching anything foreign.
Step 1: Inverse-merge settings.json (remove ONLY docsync entries)
EXECUTE using Bash tool:
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; }
DST="$ROOT/.claude/hooks"
DOCSYNC="$ROOT/.claude/docsync"
SETTINGS="$ROOT/.claude/settings.json"
# Hook files are deleted ONLY after settings.json is verifiably clean — otherwise
# live registrations would point at missing scripts and every Write/Edit/Read/Stop
# would spawn `node <deleted path>`.
CLEANED=0
if [ -f "$SETTINGS" ]; then
cp "$SETTINGS" "$SETTINGS.bak"
if command -v python3 >/dev/null 2>&1; then
SETTINGS="$SETTINGS" python3 - <<'PY'
import json, os, sys
f = os.environ["SETTINGS"]
with open(f, encoding="utf-8-sig") as fh: raw = fh.read()
if not raw.strip(): sys.exit(0)
try:
data = json.loads(raw)
except Exception as e:
sys.stderr.write("docsync: settings.json invalid JSON (%s) — ABORTING\n" % e); sys.exit(1)
hooks = data.get("hooks")
def isds(h):
# Exec-form entries carry the script path in args, shell-form in command — scan both.
c = " ".join([h.get("command") or ""] + [str(a) for a in (h.get("args") or [])])
return any(n in c for n in ("docsync-track.mjs", "docsync-watch.mjs", "docsync-gate.mjs"))
if isinstance(hooks, dict):
for ev in list(hooks.keys()):
groups = hooks.get(ev)
if not isinstance(groups, list): continue
ng = []
for g in groups:
hs = g.get("hooks")
if isinstance(hs, list):
g["hooks"] = [h for h in hs if not isds(h)]
if g.get("hooks"): # keep group only if it still has hooks
ng.append(g)
if ng: hooks[ev] = ng
else: del hooks[ev] # prune now-empty event
tmp = f + ".tmp"
json.dump(data, open(tmp, "w"), indent=2)
os.replace(tmp, f)
print("OK")
PY
[ $? -eq 0 ] && { echo "✅ settings.json cleaned (python3)"; CLEANED=1; } || { echo "❌ clean FAILED — restoring"; cp "$SETTINGS.bak" "$SETTINGS"; }
elif command -v jq >/dev/null 2>&1; then
TMP="$(mktemp)"
jq '
def isds: [(.command // "")] + ((.args // []) | map(tostring)) | join(" ")
| test("docsync-(track|watch|gate)\\.mjs");
.hooks = (
(.hooks // {})
| to_entries
| map(.value = (.value
| map(.hooks = ((.hooks // []) | map(select(isds | not))))
| map(select((.hooks // []) | length > 0))))
| map(select((.value | length) > 0))
| from_entries )
' "$SETTINGS" > "$TMP" && jq empty "$TMP" >/dev/null 2>&1 && mv "$TMP" "$SETTINGS" \
&& { echo "✅ settings.json cleaned (jq)"; CLEANED=1; } || { echo "❌ clean FAILED — backup at $SETTINGS.bak"; rm -f "$TMP"; }
else
echo "❌ neither python3 nor jq — remove the three docsync entries from $SETTINGS manually"
fi
else
echo "⚠️ no settings.json — nothing to clean"
CLEANED=1
fi
[ "$CLEANED" = 1 ] || { echo "❌ settings not cleaned — hook files KEPT to avoid broken registrations"; exit 1; }
rm -f "$DST/docsync-track.mjs" "$DST/docsync-watch.mjs" "$DST/docsync-gate.mjs" && echo "✅ hook files removed"
STOP if ❌ "settings not cleaned" — nothing was deleted, the install is intact.
Fix settings.json (or install python3/jq) and re-run uninstall.
Step 2: Ask about state dir
ASK via AskUserQuestion: "Also delete .claude/docsync/ (config + state)?"
Options: Yes, delete / Keep config.
- Yes -> EXECUTE:
rm -rf "$ROOT/.claude/docsync" && echo "✅ docsync/ removed"
- Keep -> leave it (a later
install reuses the config).
Step 3: Report
Tell the user exactly what was removed and that the .bak backup of settings.json
remains. Removal takes effect next session.
Mode: purge
uninstall with no survivors — for when the project is done with docsync entirely.
- Run every step of
uninstall Step 1 (settings inverse-merge + hook file removal),
INCLUDING its CLEANED guard. If Step 1 aborts with ❌ settings not cleaned,
purge stops there — do NOT proceed to step 2. Deleting .claude/docsync/ while
three registrations still point at the hooks is exactly the state the guard exists
to prevent.
- Skip the Step 2 question and EXECUTE unconditionally:
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; }
rm -rf "$ROOT/.claude/docsync" && echo "✅ .claude/docsync removed"
- Report what was removed. The
settings.json .bak backup is deliberately kept —
purge never touches foreign settings or the backup.
The three modes below are this skill's EXTRAS — they operate the installed
tracker rather than manage it, and so come after the whole canonical set.
Mode: sync [--all]
Sync stale docs (or ALL in-scope docs with --all) WITH confirmation.
- Build the target set: default = stale docs (as in status);
--all = every
in-scope doc (enumerate via the Bash find block).
- ASK via
AskUserQuestion: confirm which docs to sync (list them). Never
sync without confirmation.
- For each confirmed doc: READ it, then follow its
sync_procedure (if present —
no hook parses it, you do) to refresh content. Apply compression by doc_type:
llm = deep, user = light, absent = user. Preserve author intent.
- Set
last_updated: "{LAST_UPDATED}" (quoted; Bash: date +%F, LOCAL) in each synced
doc's frontmatter. A doc that had no last_updated gains one here.
- Output the Sync summary table.
Mode: reread
Force a re-read of tracked docs to refresh in-context understanding (no writes).
- Determine scope: docs in the session touched-set, else all in-scope
.md
(enumerate via the Bash find block).
- Read each with the Read tool.
- Output a short list of what was re-read. (The watch hook records these reads.)
Mode: frontmatter
Opt-in retro-add of docsync frontmatter to in-scope docs. NEVER run automatically
at install.
- Enumerate in-scope
.md (via the Bash find block, minus excludes). For each,
detect whether it already has last_updated.
- Show the list of docs missing frontmatter and the fields to add.
- ASK via
AskUserQuestion: "Add docsync frontmatter to N docs?" Options:
Yes, all / Review each / Cancel.
- For approved docs, prepend/merge a YAML frontmatter block with ALL THREE schema
fields —
sync mode reads sync_procedure, so omitting it here would emit docs
that sync cannot follow:---
doc_type: user # UNQUOTED; llm for machine-facing docs; skip to exclude
last_updated: "{LAST_UPDATED}"
sync_procedure: "<what to re-check for THIS doc, and where>"
---
Preserve any existing frontmatter keys and append these after them. Resolve
{LAST_UPDATED} with date +%F. last_updated and sync_procedure are
QUOTED, doc_type is bare (see Frontmatter schema). Write a
real one-line sync_procedure derived from what the doc actually documents; if
a doc genuinely has no procedure worth naming, omit the key rather than emit a
placeholder, and say which docs you omitted it for.
- Output the frontmatter summary table.
Verification (per mode)
Run these after acting and report pass/fail for each check.
| Mode |
Checks |
| install |
3 hook files exist in .claude/hooks/; node --check each parses; config.json valid JSON carrying all three provenance keys (version == plugin version, generated_by == brewdoc:docsync-setup, last_updated a YYYY-MM-DD date); settings.json valid JSON and contains all 3 hook commands; .bak backup present |
| upgrade |
same checks as install, plus threshold_days + exclude unchanged and the three provenance keys refreshed |
| enable |
config.json valid JSON with enabled: true; hook commands still in settings.json; hook files still present; threshold_days + exclude unchanged; all three provenance keys present and current |
| disable |
config.json valid JSON with enabled: false; same preservation + provenance checks as enable; the session state files still present |
| status |
config exists; counts add up (tracked = stale + fresh + no-date); the enabled state is stated |
| sync |
each synced doc's last_updated == today; frontmatter still valid |
| reread |
each targeted doc was actually read |
| frontmatter |
each approved doc now has valid frontmatter with a BARE doc_type + a QUOTED last_updated (+ sync_procedure wherever one was written); pre-existing keys preserved |
| uninstall |
no docsync-*.mjs command remains in settings.json; foreign hooks preserved; hook files gone; settings.json still valid JSON |
| purge |
all uninstall checks, plus .claude/docsync/ no longer exists |
EXECUTE (install/upgrade verification) using Bash tool:
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; }
DST="$ROOT/.claude/hooks"; D="$ROOT/.claude/docsync"; S="$ROOT/.claude/settings.json"; ok=1
for f in docsync-track docsync-watch docsync-gate; do
node --check "$DST/$f.mjs" && echo "✅ $f parses" || { echo "❌ $f parse FAILED"; ok=0; }
done
PJ="${CLAUDE_SKILL_DIR}/../../.claude-plugin/plugin.json"
node -e "
const fs=require('fs');
const cfg=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));
const v=JSON.parse(fs.readFileSync(process.argv[2],'utf8')).version;
if(cfg.version!==v) throw new Error('config version '+cfg.version+' != plugin '+v);
if(cfg.generated_by!=='brewdoc:docsync-setup') throw new Error('generated_by is '+cfg.generated_by);
if(!/^\d{4}-\d{2}-\d{2}\$/.test(cfg.last_updated||'')) throw new Error('last_updated not YYYY-MM-DD: '+cfg.last_updated);
if(!Number.isInteger(cfg.threshold_days)) throw new Error('threshold_days not an integer');
" "$D/config.json" "$PJ" && echo "✅ config.json valid + provenance matches plugin" || { echo "❌ config.json"; ok=0; }
node -e "const s=JSON.stringify(JSON.parse(require('fs').readFileSync('$S','utf8')));['docsync-track','docsync-watch','docsync-gate'].forEach(n=>{if(!s.includes(n))throw new Error('missing '+n)});" \
&& echo "✅ settings.json wired" || { echo "❌ settings.json missing entries"; ok=0; }
[ -f "$S.bak" ] && echo "✅ backup present" || { echo "❌ no .bak backup"; ok=0; }
[ "$ok" = 1 ] && echo "✅ VERIFY OK" || echo "❌ VERIFY FAILED"
Output Format
# docsync-setup [MODE]
## Detection
| Field | Value |
|-------|-------|
| Arguments | `$ARGUMENTS` |
| Mode | `[mode]` (reason) |
## Plan
- [what will happen]
## Actions
- [action 1]
- [action 2]
## Status
tracking: enabled | DISABLED (hooks wired but inert — `enable` resumes)
| Doc | doc_type | last_updated | age | state |
|-----|----------|--------------|-----|-------|
| ... | ... | ... | ..d | stale/fresh/no-date |
## Verification
| Check | Result |
|-------|--------|
| ... | ✅/❌ |
1---2name: docsync-setup3description: Installs project-local doc-staleness tracking (hooks) and reports/forces doc sync. Triggers: docsync, track doc staleness, doc sync status, stale docs, doc frontmatter.4---5<!-- brewcode-meta: version=6.1.4 content_version=6.0.0 generated_by=brewdoc:docsync-setup -->
6
7# docsync-setup
8
9> Project-scoped doc-staleness tracker. Installs three project-local hooks that
10> watch which `.md` docs you touch, then nag (once, at end of turn) when a touched
11> doc is stale by date. Source of truth = each doc's own frontmatter. Replaces
12> `brewdoc:auto-sync`.
13
14<instructions>
15
16## Prompt contract
17
18Position 1 of `$ARGUMENTS` is a **free-form prompt** (RU/EN) — modes and flags are optional and may
19follow in any order. Nobody types keys: resolve mode + scope FROM the prompt.
20
211. Strip flags. An explicit mode token anywhere wins outright, no scoring.
222. Else score modes by distinct whole-word keyword hits (table below). Highest unique score wins.
23 Tie with a destructive mode -> `AskUserQuestion`; tie with `status` -> `status`; tie of two
24 mutating modes -> the keyword appearing first; all zero -> `status` if installed, else `install`.
253. Empty arguments -> `status` if installed, else `install`; ask ONE scoping `AskUserQuestion` only
26 when the answer changes what gets written. A read-only run asks nothing.
274. Outcome-changing ambiguity -> ONE `AskUserQuestion` (max 4 questions) BEFORE any work.
285. Prose that is not a mode/id/path is still input: extract the id, path or target from it.
29
30Then print this block ONCE, before the first action:
31
32```
33PLAN — brewdoc:docsync-setup
34INPUT: <arguments verbatim, or "(empty)">
35MODE: <resolved> — <explicit | matched keyword: X | default>
36SCOPE: <resolved paths / target / level / flags>
37DO: <2-5 imperative bullets>
38RESULT: <what the user ends up holding>
39```
40
41Labels are literal; values follow the conversation language.
42
43## Standard flow (every run)
44
451. **Resolve mode** from the free-text prompt (`$ARGUMENTS`) — state which mode and WHY.
462. **Print the PLAN block** (see Prompt contract above) — once, before acting.
473. **Execute** the mode.
484. **Output block** — the standard formatted summary (see Output Format below).
495. **Verification (MANDATORY)** — run the checks for the mode and report pass/fail
50 per check. Never claim success unverified.
51
52Run in the main conversation (uses `AskUserQuestion`). No `context: fork`.
53
54> **Project root.** Resolve it ONCE and use it everywhere. The hooks resolve it as
55> `CLAUDE_PROJECT_DIR` -> upward walk for `.git`/`.claude` -> hook `cwd`, with NO
56> `git rev-parse` rung: they root on the nearest `.git`/`.claude` marker, which for a
57> nested `.claude` is the tracker's own project, not the enclosing checkout. The snippet
58> below is the skill's own recipe and keeps a `git rev-parse --show-toplevel` rung
59> between the env var and the walk; the two agree on every layout except a nested
60> `.claude`, where the hooks are the authority for config/state placement.
61> `input.cwd` is NOT the project root: it drifts mid-session and the hooks use it for
62> one thing only, resolving a relative `tool_input` path. Write the BARE braced
63> `${CLAUDE_PROJECT_DIR}` — the `${VAR:-fallback}` form is never substituted and always
64> loses to its fallback:
65> ```bash
66> ROOT="${CLAUDE_PROJECT_DIR}"
67> [ -n "$ROOT" ] && [ -d "$ROOT" ] || ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
68> [ -n "$ROOT" ] || { d=$PWD; until [ -d "$d/.git" ] || [ -d "$d/.claude" ] || [ "$d" = / ]; do d=$(dirname "$d"); done; [ "$d" = / ] && ROOT=$PWD || ROOT=$d; }
69> ```
70
71> **Enumerating docs.** Native `Glob`/`Grep` are no-ops on macOS Claude Code
72> (removed in CC 2.1.117+). Enumerate `.md` via the **Bash** tool (`find`/bfs), as
73> shown below; `Glob **/*.md` is a non-macOS fallback only.
74
75## Mode Resolution — prompt-driven
76
77Infer the mode from `$ARGUMENTS` (RU + EN). If a mode is named explicitly, honor
78it. Otherwise derive from intent. State the resolved mode and the reason.
79
80Canonical verbs, in order: `status | install | upgrade | enable | disable | uninstall | purge`.
81Skill-specific extras come after them: `sync [--all]`, `reread`, `frontmatter`.
82
83| Mode | EN keywords | RU keywords | Mutates? |
84|------|-------------|-------------|----------|
85| `status` | *(empty)*, status, check, show, what is stale | что устарело, показать, статус | no |
86| `install` | install | установи, настрой | yes |
87| `upgrade` | upgrade, refresh hooks | обнови хуки, переустанови | yes |
88| `enable` | enable, turn back on | включи, включи отслеживание, возобнови | yes |
89| `disable` | disable, pause, mute | выключи, приостанови, отключи отслеживание | yes |
90| `uninstall` | uninstall | удали docsync, снеси хуки | yes |
91| `purge` | purge | вычисти, снеси всё вместе с конфигом | yes, destructive |
92| `sync` | sync, sync all, `--all` | синхронизируй, обнови устаревшие | yes |
93| `reread` | reread, refresh context | перечитай, освежи | no |
94| `frontmatter` | frontmatter, add frontmatter | проставь frontmatter, ретро-разметка | yes |
95
96- `(empty)` AND hooks NOT installed -> `install`. `(empty)` AND hooks installed -> `status`.
97- Unrecognized text -> pick the closest mode; if unclear, default to `status`.
98- Prose that names no mode/id/path is still input: extract the id/path/target from the sentence,
99 never treat its first word as a positional id.
100- A missing PLAN block, or one printed after work started, is a defect.
101
102> Removed aliases — `init`, `on`, `off`, `setup`, `remove`, `reset`, `create`,
103> `update`, `cleanup` are no longer accepted verbs. Map them to the canonical set
104> above (`on` -> `enable`, `off` -> `disable`) and say so in the output. Never print
105> a removed alias back to the user as a command.
106
107> `disable` is NOT `uninstall`. It flips one key in `config.json`; the hooks stay
108> registered in `settings.json`, the hook files stay on disk, the session state files and every
109> `last_updated` you have written stay untouched. `enable` flips it back. Reach for
110> `uninstall` only when the hooks should stop existing.
111
112### First-run detection
113
114**EXECUTE** using Bash tool:
115```bash
116ROOT="${CLAUDE_PROJECT_DIR}"
117[ -n "$ROOT" ] && [ -d "$ROOT" ] || ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
118[ -n "$ROOT" ] || { d=$PWD; until [ -d "$d/.git" ] || [ -d "$d/.claude" ] || [ "$d" = / ]; do d=$(dirname "$d"); done; [ "$d" = / ] && ROOT=$PWD || ROOT=$d; }
119if [ -f "$ROOT/.claude/hooks/docsync-gate.mjs" ] && grep -q 'docsync-gate.mjs' "$ROOT/.claude/settings.json" 2>/dev/null; then
120 # `"enabled": false` means installed-but-inert, NOT missing. Absent key = enabled.
121 if grep -q '"enabled"[[:space:]]*:[[:space:]]*false' "$ROOT/.claude/docsync/config.json" 2>/dev/null; then
122 echo "docsync: INSTALLED (DISABLED)"
123 else
124 echo "docsync: INSTALLED"
125 fi
126else
127 echo "docsync: NOT_INSTALLED"
128fi
129```
130
131- `NOT_INSTALLED` + no explicit mode -> **install**.
132- `INSTALLED` (either state) + no explicit mode -> **status**.
133- A `DISABLED` install is still an install: `install` must refuse it and point at
134 `enable`; never reinstall over a deliberate pause.
135
136## Frontmatter schema (this system's docs)
137
138```yaml
139---
140doc_type: llm # optional, UNQUOTED; absent or unrecognized => user. values: llm | user | skip
141last_updated: "2026-07-19" # sole staleness input (YYYY-MM-DD, LOCAL time)
142sync_procedure: "what to check / where to look when syncing" # optional, prose
143---
144```
145
146- **Quote `last_updated` and `sync_procedure`; leave `doc_type` bare.** The hooks'
147 frontmatter parser strips surrounding quotes and trailing comments
148 (`assets/docsync-gate.mjs:136`, `docsync-track.mjs:114`, `docsync-watch.mjs:109`),
149 so either form works for docsync — but a real YAML consumer types an unquoted
150 `2026-07-19` as a Date, while `doc_type` is an enum that other brewcode tooling
151 matches literally as `^doc_type: llm$`. Existing quoted docs keep working.
152- `doc_type` drives compress depth on sync: `llm` = deep, `user` = light.
153 Absent or unrecognized is normalized to `user` in code (`docTypeOf()` in all
154 three hooks), not just in prose.
155- `doc_type: skip` = file excluded from tracking entirely — enforced by all
156 three hooks, including the Stop gate, which re-checks it at end of turn.
157- `sync_procedure` is a **model-only hint**: NO hook reads it. It is prose the
158 gate's block message and the `sync` mode tell Claude to follow after reading the
159 doc. Leaving it out costs nothing mechanical.
160- Staleness is DATE ONLY, in LOCAL time: `today - last_updated > threshold_days`.
161 No hash, no deps.
162
163## The three hooks — exact behavior
164
165| File | Event | Matcher | Behavior |
166|------|-------|---------|----------|
167| `docsync-track.mjs` | PostToolUse | `Write\|Edit\|MultiEdit` | Records the touched `.md`; injects a nudge when it has no `last_updated` |
168| `docsync-watch.mjs` | PostToolUse | `Read` | Records the touched `.md`. SILENT by design — a Read fires constantly |
169| `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 |
170
171- The gate's `asked` flag is a single per-session boolean. After the one block,
172 docs that go stale or get touched later in that session produce NO further
173 signal until the next session. This is deliberate (a Stop hook that blocks
174 repeatedly loops), not a bug — say so if a user asks why the nag stopped.
175- A doc that is only ever READ and carries no `last_updated` IS reported: the
176 gate lists it under `no last_updated`. Only `track` nudges mid-turn.
177- All three hooks apply `exclude` and `doc_type: skip`, so marking a doc `skip`
178 mid-session silences it at the gate too.
179
180## Enumerate in-scope docs (status / sync --all / reread / frontmatter)
181
182**EXECUTE** using Bash tool (lists project `.md`, minus `.git`; apply `exclude`
183globs from config and any `doc_type: skip` in your own reasoning afterward):
184```bash
185ROOT="${CLAUDE_PROJECT_DIR}"
186[ -n "$ROOT" ] && [ -d "$ROOT" ] || ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
187[ -n "$ROOT" ] || { d=$PWD; until [ -d "$d/.git" ] || [ -d "$d/.claude" ] || [ "$d" = / ]; do d=$(dirname "$d"); done; [ "$d" = / ] && ROOT=$PWD || ROOT=$d; }
188cd "$ROOT" && find . -type f -name '*.md' -not -path './.git/*' | sed 's#^\./##' | sort
189```
190
191---
192
193## Mode: install
194
195Install the tracking system into THIS project. Never adds frontmatter to docs
196(that is the opt-in `frontmatter` mode).
197
198### Step 1: Ask threshold + excludes
199
200**ASK** via `AskUserQuestion` (two questions in one call):
201
2021. "Staleness threshold — after how many days without update is a doc stale?"
203 Options: **7 (default)** / **14** / **30** / **Other** (user types a number).
2042. "Exclude globs — which `.md` paths to ignore?"
205 Options: **Common** (`node_modules/**`, `**/CHANGELOG.md`, `dist/**`, `build/**`, `vendor/**`) / **None** / **Other** (user types comma-separated globs).
206
207Record `THRESHOLD` (integer, default 7) and `EXCLUDE` (comma-separated globs).
208
209### Step 2: Copy hooks + write config + merge settings (idempotent, non-destructive)
210
211**EXECUTE** using Bash tool. Replace `THRESHOLD_VALUE` and `EXCLUDE_JSON` first:
212`THRESHOLD_VALUE` = chosen integer; `EXCLUDE_JSON` = JSON array of the chosen globs
213(e.g. `["node_modules/**","**/CHANGELOG.md"]`, or `[]` for none).
214
215```bash
216ROOT="${CLAUDE_PROJECT_DIR}"
217[ -n "$ROOT" ] && [ -d "$ROOT" ] || ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
218[ -n "$ROOT" ] || { d=$PWD; until [ -d "$d/.git" ] || [ -d "$d/.claude" ] || [ "$d" = / ]; do d=$(dirname "$d"); done; [ "$d" = / ] && ROOT=$PWD || ROOT=$d; }
219SRC="${CLAUDE_SKILL_DIR}/assets"
220DST="$ROOT/.claude/hooks"
221DOCSYNC="$ROOT/.claude/docsync"
222SETTINGS="$ROOT/.claude/settings.json"
223
224# Plugin version by skill self-location — NEVER hardcode it. config.json is the anchor
225# artifact other tooling (e.g. /brewcode:setup-status) reads the installed version from.
226PLUGIN_JSON="${CLAUDE_SKILL_DIR}/../../.claude-plugin/plugin.json"
227PV=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).version||'')" "$PLUGIN_JSON" 2>/dev/null || true)
228[ -n "$PV" ] || { echo "❌ cannot read version from $PLUGIN_JSON — reinstall brewdoc"; exit 1; }
229
230# content_version — this SKILL.md's own header marker, self-located the same way PV is.
231SKILL_MD="${CLAUDE_SKILL_DIR}/SKILL.md"
232CV=$(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')
233[ -n "$CV" ] || { echo "❌ cannot read content_version from $SKILL_MD — reinstall brewdoc"; exit 1; }
234
235# What existed BEFORE this run — a failed settings merge rolls back only what it created,
236# never a working install's files (install Step 2 is re-run verbatim by `upgrade`).
237HOOKS_EXISTED=1
238for f in docsync-track docsync-watch docsync-gate; do [ -f "$DST/$f.mjs" ] || HOOKS_EXISTED=0; done
239[ -f "$DOCSYNC/config.json" ] && CFG_EXISTED=1 || CFG_EXISTED=0
240
241mkdir -p "$DST" "$DOCSYNC" \
242 && cp "$SRC/docsync-track.mjs" "$SRC/docsync-watch.mjs" "$SRC/docsync-gate.mjs" "$DST/" \
243 && echo "✅ hooks copied to $DST" || { echo "❌ copy FAILED"; exit 1; }
244
245rollback() {
246 cp "$SETTINGS.bak" "$SETTINGS" 2>/dev/null
247 [ "$HOOKS_EXISTED" = 1 ] || rm -f "$DST/docsync-track.mjs" "$DST/docsync-watch.mjs" "$DST/docsync-gate.mjs"
248 [ "$CFG_EXISTED" = 1 ] || rm -f "$DOCSYNC/config.json"
249 echo "↩️ rolled back — settings restored, nothing half-installed left behind"
250}
251
252# config.json — replace the two placeholders below before running.
253# The four provenance keys come first, in the standard order, then the skill-private ones.
254printf '{ "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" \
255 && node -e "JSON.parse(require('fs').readFileSync('$DOCSYNC/config.json','utf8'))" \
256 && echo "✅ config.json written (version $PV, content_version $CV)" || { echo "❌ config.json invalid JSON"; exit 1; }
257
258# State files are per session (`state-<session_id>.json`) and owned by the hooks —
259# install seeds nothing. A pre-6.0 `state.json` is left alone; the gate prunes it.
260mkdir -p "$(dirname "$SETTINGS")"
261[ -f "$SETTINGS" ] || echo '{}' > "$SETTINGS"
262# Backup BEFORE any write — merge must never lose foreign hooks/permissions/env.
263cp "$SETTINGS" "$SETTINGS.bak"
264
265# Exec form (upstream's stated preference for any hook referencing a path placeholder):
266# the placeholder is substituted per `args` element on every shell, whereas a shell-form
267# `$CLAUDE_PROJECT_DIR` resolves to $null under PowerShell and launches node on "/.claude/…".
268# The token is ASSEMBLED here on purpose — written literally it would be substituted into
269# this machine's absolute path by the skill loader and the committed settings.json would
270# stop being portable.
271D='$'; PD="${D}{CLAUDE_PROJECT_DIR}"
272T_ARG="$PD/.claude/hooks/docsync-track.mjs"
273W_ARG="$PD/.claude/hooks/docsync-watch.mjs"
274G_ARG="$PD/.claude/hooks/docsync-gate.mjs"
275
276if command -v python3 >/dev/null 2>&1; then
277 SETTINGS="$SETTINGS" T_ARG="$T_ARG" W_ARG="$W_ARG" G_ARG="$G_ARG" python3 - <<'PY'
278import json, os, sys
279f = os.environ["SETTINGS"]
280raw = ""
281if os.path.exists(f):
282 with open(f, encoding="utf-8-sig") as fh: # BOM-tolerant
283 raw = fh.read()
284if raw.strip():
285 try:
286 data = json.loads(raw)
287 except Exception as e:
288 sys.stderr.write("docsync: settings.json is not valid JSON (%s) — ABORTING, not clobbering\n" % e)
289 sys.exit(1)
290else:
291 data = {}
292hooks = data.setdefault("hooks", {})
293# Idempotency scans command AND args — exec-form entries carry the path in args.
294def text(h):
295 return " ".join([h.get("command") or ""] + [str(a) for a in (h.get("args") or [])])
296def has(event, needle):
297 return any(needle in text(h) for g in hooks.get(event, []) for h in g.get("hooks", []))
298def add(event, matcher, arg, needle):
299 if has(event, needle): return
300 groups = hooks.setdefault(event, [])
301 if matcher:
302 grp = next((g for g in groups if g.get("matcher") == matcher), None)
303 else:
304 grp = next((g for g in groups if not g.get("matcher")), None)
305 entry = {"type": "command", "command": "node", "args": [arg]}
306 if grp is not None:
307 grp.setdefault("hooks", []).append(entry)
308 else:
309 groups.append({"matcher": matcher, "hooks": [entry]} if matcher else {"hooks": [entry]})
310add("PostToolUse", "Write|Edit|MultiEdit", os.environ["T_ARG"], "docsync-track.mjs")
311add("PostToolUse", "Read", os.environ["W_ARG"], "docsync-watch.mjs")
312add("Stop", "", os.environ["G_ARG"], "docsync-gate.mjs")
313tmp = f + ".tmp"
314json.dump(data, open(tmp, "w"), indent=2)
315os.replace(tmp, f)
316print("OK")
317PY
318 [ $? -eq 0 ] && echo "✅ settings.json merged (python3)" || { echo "❌ merge FAILED"; rollback; exit 1; }
319elif command -v jq >/dev/null 2>&1; then
320 TMP="$(mktemp)"
321 jq --arg t "$T_ARG" --arg w "$W_ARG" --arg g "$G_ARG" '
322 def text: [(.command // "")] + ((.args // []) | map(tostring)) | join(" ");
323 def has(ev; needle): (.hooks[ev] // []) | map(.hooks // [] | map(text) | any(test(needle))) | any;
324 def entry(arg): {"type":"command","command":"node","args":[arg]};
325 def add(ev; matcher; arg; needle):
326 if has(ev; needle) then .
327 else
328 .hooks[ev] = (.hooks[ev] // [])
329 | ( if matcher == "" then (.hooks[ev] | map((.matcher // "") == "") | index(true))
330 else (.hooks[ev] | map((.matcher // "") == matcher) | index(true)) end) as $i
331 | if $i != null then .hooks[ev][$i].hooks += [entry(arg)]
332 else .hooks[ev] += [ (if matcher == "" then {"hooks":[entry(arg)]}
333 else {"matcher":matcher,"hooks":[entry(arg)]} end) ] end
334 end;
335 .hooks = (.hooks // {})
336 | add("PostToolUse"; "Write|Edit|MultiEdit"; $t; "docsync-track\\.mjs")
337 | add("PostToolUse"; "Read"; $w; "docsync-watch\\.mjs")
338 | add("Stop"; ""; $g; "docsync-gate\\.mjs")
339 ' "$SETTINGS" > "$TMP" && jq empty "$TMP" >/dev/null 2>&1 && mv "$TMP" "$SETTINGS" \
340 && echo "✅ settings.json merged (jq)" || { echo "❌ merge FAILED"; rm -f "$TMP"; rollback; exit 1; }
341else
342 # Not a failure to roll back: the files must stay so the user can wire them by hand.
343 echo "❌ neither python3 nor jq — hooks + config KEPT; add the three entries from assets/INSTALL.md manually"
344fi
345```
346
347> **STOP if ❌** — the pre-write backup is at `$SETTINGS.bak`. See
348> `${CLAUDE_SKILL_DIR}/assets/INSTALL.md` for the manual entries.
349
350### Step 3: Report + tell the user
351
352State exactly what changed: 3 hooks copied, `config.json` (threshold + excludes)
353written, `settings.json` merged (PostToolUse `Write|Edit|MultiEdit` -> track,
354PostToolUse `Read` -> watch, Stop -> gate) with a `.bak` backup. Remind: hooks take
355effect on the NEXT session (SessionStart on next `claude` start / `--resume`), and
356require `node` on `PATH` for the shell that runs hooks. Suggest running
357`frontmatter` next if the project's docs lack `last_updated`.
358
359---
360
361## Mode: upgrade
362
363Refresh an EXISTING install to the current plugin version. Config and state survive.
364
3651. Require `INSTALLED` from first-run detection. If `NOT_INSTALLED` -> say so and
366 run `install` instead.
3672. Re-copy the three hook files from `${CLAUDE_SKILL_DIR}/assets` over
368 `$ROOT/.claude/hooks/` (same `cp` as install Step 2), leaving the session state
369 files untouched.
3703. Refresh ONLY the three provenance keys in `.claude/docsync/config.json` —
371 `version`, `generated_by`, `last_updated`. `threshold_days`, `exclude` and
372 `enabled` are preserved verbatim: upgrading a DISABLED install must leave it
373 disabled.
374
375 **EXECUTE** using Bash tool:
376 ```bash
377 ROOT="${CLAUDE_PROJECT_DIR}"
378 [ -n "$ROOT" ] && [ -d "$ROOT" ] || ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
379 [ -n "$ROOT" ] || { d=$PWD; until [ -d "$d/.git" ] || [ -d "$d/.claude" ] || [ "$d" = / ]; do d=$(dirname "$d"); done; [ "$d" = / ] && ROOT=$PWD || ROOT=$d; }
380 C="$ROOT/.claude/docsync/config.json"
381 PJ="${CLAUDE_SKILL_DIR}/../../.claude-plugin/plugin.json"
382 SKILL_MD="${CLAUDE_SKILL_DIR}/SKILL.md"
383 node -e '
384 const fs = require("fs");
385 const [c, pj, today, skillMd] = process.argv.slice(1);
386 const v = JSON.parse(fs.readFileSync(pj, "utf8")).version;
387 if (!v) throw new Error("no version in " + pj);
388 const header = fs.readFileSync(skillMd, "utf8").split("\n").find(l => l.includes("brewcode-meta:")) || "";
389 const cvm = /content_version=([0-9]+\.[0-9]+\.[0-9]+)/.exec(header);
390 if (!cvm) throw new Error("no content_version in " + skillMd);
391 const cv = cvm[1];
392 const cfg = JSON.parse(fs.readFileSync(c, "utf8"));
393 const was = cfg.version || "(none)";
394 const { version, content_version, generated_by, last_updated, ...rest } = cfg;
395 const next = { version: v, content_version: cv, generated_by: "brewdoc:docsync-setup", last_updated: today, ...rest };
396 fs.writeFileSync(c, JSON.stringify(next, null, 2) + "\n");
397 console.log(`config.json version ${was} -> ${v}; content_version=${next.content_version}, generated_by=${next.generated_by}, last_updated=${next.last_updated}; enabled=${next.enabled !== false}, threshold_days=${next.threshold_days}, exclude=${JSON.stringify(next.exclude)}`);
398 ' "$C" "$PJ" "$(date +%F)" "$SKILL_MD" && echo "✅ config provenance refreshed" || { echo "❌ config provenance refresh FAILED"; exit 1; }
399 ```
4004. Re-run the settings merge from install Step 2 — it is idempotent, so it only
401 restores entries a user or another tool dropped.
4025. Run the install verification block and report per-check pass/fail, plus
403 `threshold_days` + `exclude` + `enabled` unchanged.
404
405---
406
407## Mode: status
408
409Report tracked docs and staleness. No changes.
410
4111. Read `$ROOT/.claude/docsync/config.json` (threshold + excludes + `enabled`). If
412 missing -> "not installed; run install". If `enabled` is `false`, lead the report
413 with **DISABLED — hooks are wired but inert; `enable` resumes them**, then report
414 staleness anyway: the numbers stay meaningful while the tracker is paused.
4152. Enumerate in-scope docs via the Bash `find` block above; drop `exclude` matches
416 and any with `doc_type: skip`.
4173. For each, read frontmatter `last_updated`; compute age in days (LOCAL time);
418 mark stale when `age > threshold_days`; mark `no-date` when missing.
4194. Read `$ROOT/.claude/docsync/state-<session_id>.json` (one file per session; a
420 pre-6.0 install may still carry a shared `state.json`) and report the current
421 session touched-set.
4225. Output the Status table (below).
423
424## Mode: enable / disable
425
426Flip docsync between live and inert WITHOUT unwiring anything. One key,
427`"enabled"`, in `.claude/docsync/config.json`:
428
429| | hooks in `settings.json` | hook files | `config.json` | session state | doc frontmatter |
430|---|---|---|---|---|---|
431| `disable` | kept | kept | `enabled: false` + provenance refreshed | kept | untouched |
432| `enable` | kept | kept | `enabled: true` + provenance refreshed | kept | untouched |
433| `uninstall` | removed | removed | kept | kept | untouched |
434| `purge` | removed | removed | deleted | deleted | untouched |
435
436All three hooks read `enabled` on every invocation (`loadConfig`, absent = `true`),
437so the flip takes effect IMMEDIATELY — no session restart, unlike install/uninstall
438which change `settings.json`. Disabled means: no touched-set recording, no
439frontmatter nudge, and the Stop gate never blocks.
440
4411. Require `INSTALLED` (either state) from first-run detection. `NOT_INSTALLED` ->
442 say so and offer `install`; do not write a config for hooks that do not exist.
4432. Read the current value. Short-circuit ONLY when it already matches the requested
444 verb AND the three provenance keys are current (`version` == plugin version,
445 `generated_by` == `brewdoc:docsync-setup`, `last_updated` present) — report
446 `already enabled` / `already disabled` and stop, nothing written. A config whose
447 value already matches but whose provenance is missing or stale IS rewritten: every
448 mode that writes this file stamps it, so a pre-standard config gets backfilled here
449 instead of staying unstamped forever.
4503. **EXECUTE** using Bash tool (`WANT` = `true` for enable, `false` for disable):
451 ```bash
452 ROOT="${CLAUDE_PROJECT_DIR}"
453 [ -n "$ROOT" ] && [ -d "$ROOT" ] || ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
454 [ -n "$ROOT" ] || { d=$PWD; until [ -d "$d/.git" ] || [ -d "$d/.claude" ] || [ "$d" = / ]; do d=$(dirname "$d"); done; [ "$d" = / ] && ROOT=$PWD || ROOT=$d; }
455 C="$ROOT/.claude/docsync/config.json"
456 PJ="${CLAUDE_SKILL_DIR}/../../.claude-plugin/plugin.json"
457 SKILL_MD="${CLAUDE_SKILL_DIR}/SKILL.md"
458 WANT=true # <- set to false for `disable`
459 [ -f "$C" ] || { echo "❌ $C missing — docsync is not installed"; exit 1; }
460 cp "$C" "$C.bak"
461 C="$C" WANT="$WANT" PJ="$PJ" SKILL_MD="$SKILL_MD" TODAY="$(date +%F)" node -e '
462 const fs = require("fs");
463 const c = process.env.C, want = process.env.WANT === "true";
464 const v = JSON.parse(fs.readFileSync(process.env.PJ, "utf8")).version;
465 if (!v) throw new Error("no version in " + process.env.PJ);
466 const header = fs.readFileSync(process.env.SKILL_MD, "utf8").split("\n").find(l => l.includes("brewcode-meta:")) || "";
467 const cvm = /content_version=([0-9]+\.[0-9]+\.[0-9]+)/.exec(header);
468 if (!cvm) throw new Error("no content_version in " + process.env.SKILL_MD);
469 const cv = cvm[1];
470 const cfg = JSON.parse(fs.readFileSync(c, "utf8"));
471 const was = cfg.enabled !== false;
472 const stamped = cfg.version === v && cfg.content_version === cv && cfg.generated_by === "brewdoc:docsync-setup" && /^\d{4}-\d{2}-\d{2}$/.test(cfg.last_updated || "");
473 if (was === want && stamped) { console.log(`already ${want ? "enabled" : "disabled"}, provenance current — nothing written`); process.exit(0); }
474 cfg.enabled = want;
475 const { version, content_version, generated_by, last_updated, ...rest } = cfg;
476 const next = { version: v, content_version: cv, generated_by: "brewdoc:docsync-setup", last_updated: process.env.TODAY, ...rest };
477 fs.writeFileSync(c, JSON.stringify(next, null, 2) + "\n");
478 console.log(`enabled: ${was} -> ${want}; version=${next.version}, content_version=${next.content_version}, generated_by=${next.generated_by}, last_updated=${next.last_updated}; threshold_days=${next.threshold_days}, exclude=${JSON.stringify(next.exclude)} (preserved)`);
479 ' && echo "✅ done" || { echo "❌ FAILED"; exit 1; }
480 ```
481 > **STOP if ❌** — fix before continuing.
4824. Verify: `config.json` is still valid JSON, `enabled` holds the requested value,
483 `threshold_days` + `exclude` are byte-unchanged, and the three provenance keys are
484 present and current (`version` == plugin version, `generated_by` ==
485 `brewdoc:docsync-setup`, `last_updated` == today).
4865. Report the new state and its reversal verb. After `disable`, say the hooks are
487 still registered and `enable` brings them back with zero re-analysis.
488
489---
490
491## Mode: uninstall
492
493Remove docsync from THIS project without touching anything foreign.
494
495### Step 1: Inverse-merge settings.json (remove ONLY docsync entries)
496
497**EXECUTE** using Bash tool:
498```bash
499ROOT="${CLAUDE_PROJECT_DIR}"
500[ -n "$ROOT" ] && [ -d "$ROOT" ] || ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
501[ -n "$ROOT" ] || { d=$PWD; until [ -d "$d/.git" ] || [ -d "$d/.claude" ] || [ "$d" = / ]; do d=$(dirname "$d"); done; [ "$d" = / ] && ROOT=$PWD || ROOT=$d; }
502DST="$ROOT/.claude/hooks"
503DOCSYNC="$ROOT/.claude/docsync"
504SETTINGS="$ROOT/.claude/settings.json"
505
506# Hook files are deleted ONLY after settings.json is verifiably clean — otherwise
507# live registrations would point at missing scripts and every Write/Edit/Read/Stop
508# would spawn `node <deleted path>`.
509CLEANED=0
510
511if [ -f "$SETTINGS" ]; then
512 cp "$SETTINGS" "$SETTINGS.bak"
513 if command -v python3 >/dev/null 2>&1; then
514 SETTINGS="$SETTINGS" python3 - <<'PY'
515import json, os, sys
516f = os.environ["SETTINGS"]
517with open(f, encoding="utf-8-sig") as fh: raw = fh.read()
518if not raw.strip(): sys.exit(0)
519try:
520 data = json.loads(raw)
521except Exception as e:
522 sys.stderr.write("docsync: settings.json invalid JSON (%s) — ABORTING\n" % e); sys.exit(1)
523hooks = data.get("hooks")
524def isds(h):
525 # Exec-form entries carry the script path in args, shell-form in command — scan both.
526 c = " ".join([h.get("command") or ""] + [str(a) for a in (h.get("args") or [])])
527 return any(n in c for n in ("docsync-track.mjs", "docsync-watch.mjs", "docsync-gate.mjs"))
528if isinstance(hooks, dict):
529 for ev in list(hooks.keys()):
530 groups = hooks.get(ev)
531 if not isinstance(groups, list): continue
532 ng = []
533 for g in groups:
534 hs = g.get("hooks")
535 if isinstance(hs, list):
536 g["hooks"] = [h for h in hs if not isds(h)]
537 if g.get("hooks"): # keep group only if it still has hooks
538 ng.append(g)
539 if ng: hooks[ev] = ng
540 else: del hooks[ev] # prune now-empty event
541tmp = f + ".tmp"
542json.dump(data, open(tmp, "w"), indent=2)
543os.replace(tmp, f)
544print("OK")
545PY
546 [ $? -eq 0 ] && { echo "✅ settings.json cleaned (python3)"; CLEANED=1; } || { echo "❌ clean FAILED — restoring"; cp "$SETTINGS.bak" "$SETTINGS"; }
547 elif command -v jq >/dev/null 2>&1; then
548 TMP="$(mktemp)"
549 jq '
550 def isds: [(.command // "")] + ((.args // []) | map(tostring)) | join(" ")
551 | test("docsync-(track|watch|gate)\\.mjs");
552 .hooks = (
553 (.hooks // {})
554 | to_entries
555 | map(.value = (.value
556 | map(.hooks = ((.hooks // []) | map(select(isds | not))))
557 | map(select((.hooks // []) | length > 0))))
558 | map(select((.value | length) > 0))
559 | from_entries )
560 ' "$SETTINGS" > "$TMP" && jq empty "$TMP" >/dev/null 2>&1 && mv "$TMP" "$SETTINGS" \
561 && { echo "✅ settings.json cleaned (jq)"; CLEANED=1; } || { echo "❌ clean FAILED — backup at $SETTINGS.bak"; rm -f "$TMP"; }
562 else
563 echo "❌ neither python3 nor jq — remove the three docsync entries from $SETTINGS manually"
564 fi
565else
566 echo "⚠️ no settings.json — nothing to clean"
567 CLEANED=1
568fi
569
570[ "$CLEANED" = 1 ] || { echo "❌ settings not cleaned — hook files KEPT to avoid broken registrations"; exit 1; }
571rm -f "$DST/docsync-track.mjs" "$DST/docsync-watch.mjs" "$DST/docsync-gate.mjs" && echo "✅ hook files removed"
572```
573
574> **STOP if ❌ "settings not cleaned"** — nothing was deleted, the install is intact.
575> Fix `settings.json` (or install `python3`/`jq`) and re-run `uninstall`.
576
577### Step 2: Ask about state dir
578
579**ASK** via `AskUserQuestion`: "Also delete `.claude/docsync/` (config + state)?"
580Options: **Yes, delete** / **Keep config**.
581
582- Yes -> **EXECUTE**: `rm -rf "$ROOT/.claude/docsync" && echo "✅ docsync/ removed"`
583- Keep -> leave it (a later `install` reuses the config).
584
585### Step 3: Report
586
587Tell the user exactly what was removed and that the `.bak` backup of settings.json
588remains. Removal takes effect next session.
589
590## Mode: purge
591
592`uninstall` with no survivors — for when the project is done with docsync entirely.
593
5941. Run every step of `uninstall` Step 1 (settings inverse-merge + hook file removal),
595 INCLUDING its `CLEANED` guard. If Step 1 aborts with `❌ settings not cleaned`,
596 purge stops there — do NOT proceed to step 2. Deleting `.claude/docsync/` while
597 three registrations still point at the hooks is exactly the state the guard exists
598 to prevent.
5992. Skip the Step 2 question and **EXECUTE** unconditionally:
600 ```bash
601 ROOT="${CLAUDE_PROJECT_DIR}"
602 [ -n "$ROOT" ] && [ -d "$ROOT" ] || ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
603 [ -n "$ROOT" ] || { d=$PWD; until [ -d "$d/.git" ] || [ -d "$d/.claude" ] || [ "$d" = / ]; do d=$(dirname "$d"); done; [ "$d" = / ] && ROOT=$PWD || ROOT=$d; }
604 rm -rf "$ROOT/.claude/docsync" && echo "✅ .claude/docsync removed"
605 ```
6063. Report what was removed. The `settings.json` `.bak` backup is deliberately kept —
607 purge never touches foreign settings or the backup.
608
609---
610
611> The three modes below are this skill's EXTRAS — they operate the installed
612> tracker rather than manage it, and so come after the whole canonical set.
613
614## Mode: sync `[--all]`
615
616Sync stale docs (or ALL in-scope docs with `--all`) WITH confirmation.
617
6181. Build the target set: default = stale docs (as in status); `--all` = every
619 in-scope doc (enumerate via the Bash `find` block).
6202. **ASK** via `AskUserQuestion`: confirm which docs to sync (list them). Never
621 sync without confirmation.
6223. For each confirmed doc: READ it, then follow its `sync_procedure` (if present —
623 no hook parses it, you do) to refresh content. Apply compression by `doc_type`:
624 `llm` = deep, `user` = light, absent = `user`. Preserve author intent.
6254. Set `last_updated: "{LAST_UPDATED}"` (quoted; `Bash: date +%F`, LOCAL) in each synced
626 doc's frontmatter. A doc that had no `last_updated` gains one here.
6275. Output the Sync summary table.
628
629## Mode: reread
630
631Force a re-read of tracked docs to refresh in-context understanding (no writes).
632
6331. Determine scope: docs in the session touched-set, else all in-scope `.md`
634 (enumerate via the Bash `find` block).
6352. Read each with the Read tool.
6363. Output a short list of what was re-read. (The watch hook records these reads.)
637
638## Mode: frontmatter
639
640Opt-in retro-add of docsync frontmatter to in-scope docs. NEVER run automatically
641at install.
642
6431. Enumerate in-scope `.md` (via the Bash `find` block, minus excludes). For each,
644 detect whether it already has `last_updated`.
6452. Show the list of docs missing frontmatter and the fields to add.
6463. **ASK** via `AskUserQuestion`: "Add docsync frontmatter to N docs?" Options:
647 **Yes, all** / **Review each** / **Cancel**.
6484. For approved docs, prepend/merge a YAML frontmatter block with ALL THREE schema
649 fields — `sync` mode reads `sync_procedure`, so omitting it here would emit docs
650 that `sync` cannot follow:
651 ```yaml
652 ---
653 doc_type: user # UNQUOTED; llm for machine-facing docs; skip to exclude
654 last_updated: "{LAST_UPDATED}"
655 sync_procedure: "<what to re-check for THIS doc, and where>"
656 ---
657 ```
658 Preserve any existing frontmatter keys and append these after them. Resolve
659 `{LAST_UPDATED}` with `date +%F`. `last_updated` and `sync_procedure` are
660 QUOTED, `doc_type` is bare (see Frontmatter schema). Write a
661 real one-line `sync_procedure` derived from what the doc actually documents; if
662 a doc genuinely has no procedure worth naming, omit the key rather than emit a
663 placeholder, and say which docs you omitted it for.
6645. Output the frontmatter summary table.
665
666</instructions>
667
668## Verification (per mode)
669
670Run these after acting and report pass/fail for each check.
671
672| Mode | Checks |
673|------|--------|
674| install | 3 hook files exist in `.claude/hooks/`; `node --check` each parses; `config.json` valid JSON carrying all three provenance keys (`version` == plugin version, `generated_by` == `brewdoc:docsync-setup`, `last_updated` a `YYYY-MM-DD` date); `settings.json` valid JSON and contains all 3 hook commands; `.bak` backup present |
675| upgrade | same checks as `install`, plus `threshold_days` + `exclude` unchanged and the three provenance keys refreshed |
676| enable | `config.json` valid JSON with `enabled: true`; hook commands still in `settings.json`; hook files still present; `threshold_days` + `exclude` unchanged; all three provenance keys present and current |
677| disable | `config.json` valid JSON with `enabled: false`; same preservation + provenance checks as `enable`; the session state files still present |
678| status | config exists; counts add up (tracked = stale + fresh + no-date); the `enabled` state is stated |
679| sync | each synced doc's `last_updated` == today; frontmatter still valid |
680| reread | each targeted doc was actually read |
681| frontmatter | each approved doc now has valid frontmatter with a BARE `doc_type` + a QUOTED `last_updated` (+ `sync_procedure` wherever one was written); pre-existing keys preserved |
682| uninstall | no `docsync-*.mjs` command remains in `settings.json`; foreign hooks preserved; hook files gone; `settings.json` still valid JSON |
683| purge | all `uninstall` checks, plus `.claude/docsync/` no longer exists |
684
685**EXECUTE** (install/upgrade verification) using Bash tool:
686```bash
687ROOT="${CLAUDE_PROJECT_DIR}"
688[ -n "$ROOT" ] && [ -d "$ROOT" ] || ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
689[ -n "$ROOT" ] || { d=$PWD; until [ -d "$d/.git" ] || [ -d "$d/.claude" ] || [ "$d" = / ]; do d=$(dirname "$d"); done; [ "$d" = / ] && ROOT=$PWD || ROOT=$d; }
690DST="$ROOT/.claude/hooks"; D="$ROOT/.claude/docsync"; S="$ROOT/.claude/settings.json"; ok=1
691for f in docsync-track docsync-watch docsync-gate; do
692 node --check "$DST/$f.mjs" && echo "✅ $f parses" || { echo "❌ $f parse FAILED"; ok=0; }
693done
694PJ="${CLAUDE_SKILL_DIR}/../../.claude-plugin/plugin.json"
695node -e "
696 const fs=require('fs');
697 const cfg=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));
698 const v=JSON.parse(fs.readFileSync(process.argv[2],'utf8')).version;
699 if(cfg.version!==v) throw new Error('config version '+cfg.version+' != plugin '+v);
700 if(cfg.generated_by!=='brewdoc:docsync-setup') throw new Error('generated_by is '+cfg.generated_by);
701 if(!/^\d{4}-\d{2}-\d{2}\$/.test(cfg.last_updated||'')) throw new Error('last_updated not YYYY-MM-DD: '+cfg.last_updated);
702 if(!Number.isInteger(cfg.threshold_days)) throw new Error('threshold_days not an integer');
703" "$D/config.json" "$PJ" && echo "✅ config.json valid + provenance matches plugin" || { echo "❌ config.json"; ok=0; }
704node -e "const s=JSON.stringify(JSON.parse(require('fs').readFileSync('$S','utf8')));['docsync-track','docsync-watch','docsync-gate'].forEach(n=>{if(!s.includes(n))throw new Error('missing '+n)});" \
705 && echo "✅ settings.json wired" || { echo "❌ settings.json missing entries"; ok=0; }
706[ -f "$S.bak" ] && echo "✅ backup present" || { echo "❌ no .bak backup"; ok=0; }
707[ "$ok" = 1 ] && echo "✅ VERIFY OK" || echo "❌ VERIFY FAILED"
708```
709
710## Output Format
711
712```markdown
713# docsync-setup [MODE]
714
715## Detection
716| Field | Value |
717|-------|-------|
718| Arguments | `$ARGUMENTS` |
719| Mode | `[mode]` (reason) |
720
721## Plan
722- [what will happen]
723
724## Actions
725- [action 1]
726- [action 2]
727
728## Status
729tracking: enabled | DISABLED (hooks wired but inert — `enable` resumes)
730
731| Doc | doc_type | last_updated | age | state |
732|-----|----------|--------------|-----|-------|
733| ... | ... | ... | ..d | stale/fresh/no-date |
734
735## Verification
736| Check | Result |
737|-------|--------|
738| ... | ✅/❌ |
739```