chore: add Claude Project Knowledge sync tooling

- scripts/sync-claude-docs.sh with sync/check/list/help subcommands
- scripts/install-claude-sync-hooks.sh for one-time hook setup
- .githooks/post-commit auto-syncs on dev-doc changes
- .githooks/pre-push warns (non-blocking) on stale sync
- .claude-sync.conf lists 11 synced documents
- SYNC_MANIFEST.md provides drift-detection anchor for Claude Chat
- package.json: npm run sync:docs | sync:check
- .gitignore excludes .claude-sync/ output directory

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 12:38:26 +02:00
parent 83821b1bd5
commit 79189a64e5
8 changed files with 480 additions and 0 deletions

11
.claude-sync.conf Normal file
View File

@@ -0,0 +1,11 @@
CLAUDE.md
.cursorrules
dev-docs/SCHEMA.md
dev-docs/API.md
dev-docs/ARCH-FORM-BUILDER.md
dev-docs/AUTH_ARCHITECTURE.md
dev-docs/VUEXY_COMPONENTS.md
dev-docs/BACKLOG.md
dev-docs/SECURITY_AUDIT.md
dev-docs/design-document.md
dev-docs/UX_SPEC_FESTIVAL_HIERARCHY.md

54
.githooks/post-commit Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
# Auto-sync Claude Project Knowledge when a commit touched any configured doc.
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "${REPO_ROOT}" ]; then
exit 0
fi
cd "${REPO_ROOT}"
CONF_FILE=".claude-sync.conf"
SYNC_SCRIPT="scripts/sync-claude-docs.sh"
if [ ! -f "${CONF_FILE}" ] || [ ! -f "${SYNC_SCRIPT}" ]; then
exit 0
fi
# Files changed in the commit we just made.
changed="$(git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null || true)"
if [ -z "${changed}" ]; then
exit 0
fi
# Configured paths (strip # comments and blanks).
configured="$(awk '
{
sub(/#.*/, "")
gsub(/^[[:space:]]+|[[:space:]]+$/, "")
if (length($0) > 0) print
}
' "${CONF_FILE}")"
match=0
while IFS= read -r cfile; do
[ -z "${cfile}" ] && continue
while IFS= read -r conf_path; do
[ -z "${conf_path}" ] && continue
if [ "${cfile}" = "${conf_path}" ]; then
match=1
break 2
fi
done <<EOF
${configured}
EOF
done <<EOF
${changed}
EOF
if [ "${match}" -eq 1 ]; then
bash "${SYNC_SCRIPT}" sync
fi
exit 0

29
.githooks/pre-push Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Warn (non-blocking) if .claude-sync/ is stale relative to current source files.
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "${REPO_ROOT}" ]; then
exit 0
fi
cd "${REPO_ROOT}"
SYNC_SCRIPT="scripts/sync-claude-docs.sh"
if [ ! -f "${SYNC_SCRIPT}" ]; then
exit 0
fi
set +e
bash "${SYNC_SCRIPT}" check >/dev/null 2>&1
rc=$?
set -e
if [ "${rc}" -ne 0 ]; then
{
printf '%s\n' "⚠️ .claude-sync/ is stale relative to current dev-docs."
printf '%s\n' " Fix: bash scripts/sync-claude-docs.sh sync"
printf '%s\n' " (push is NOT blocked)"
} >&2
fi
exit 0

3
.gitignore vendored
View File

@@ -54,3 +54,6 @@ docs/.vitepress/cache
# Local deploy wrapper (per-developer SSH alias)
/run-deploy-from-local.sh
!/run-deploy-from-local.example.sh
# Claude Project Knowledge sync output (regenerated by scripts/sync-claude-docs.sh)
.claude-sync/

8
package.json Normal file
View File

@@ -0,0 +1,8 @@
{
"name": "crewli",
"private": true,
"scripts": {
"sync:docs": "bash scripts/sync-claude-docs.sh sync",
"sync:check": "bash scripts/sync-claude-docs.sh check"
}
}

40
scripts/README.md Normal file
View File

@@ -0,0 +1,40 @@
# Scripts
Repo-level helper scripts.
## Claude Project Knowledge sync
Keeps the Crewli dev-docs in Claude Project Knowledge aligned with the repo.
### One-time setup
```bash
bash scripts/install-claude-sync-hooks.sh
```
This points git at `.githooks/` and marks the hooks executable.
### Manual trigger
```bash
npm run sync:docs # regenerate .claude-sync/
npm run sync:check # verify .claude-sync/ matches dev-docs
```
The underlying script also supports `list` and `help`:
```bash
bash scripts/sync-claude-docs.sh list
bash scripts/sync-claude-docs.sh help
```
### Automatic
- `post-commit`: syncs automatically when any file in `.claude-sync.conf` is committed
- `pre-push`: warns if `.claude-sync/` is stale before pushing (non-blocking)
### After sync
Upload everything in `.claude-sync/` (including `SYNC_MANIFEST.md`) to the
Crewli Claude Project Knowledge, replacing existing versions. The manifest is
what Claude Chat uses for drift detection against the current HEAD.

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
# One-time installer for the Claude Project Knowledge sync hooks.
# Points git at the versioned .githooks/ directory and ensures hooks are executable.
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "${REPO_ROOT}" ]; then
printf '%s\n' "❌ Not inside a git repository." >&2
exit 1
fi
cd "${REPO_ROOT}"
if [ ! -d ".githooks" ]; then
printf '%s\n' "❌ .githooks/ directory missing at repo root." >&2
exit 1
fi
git config core.hooksPath .githooks
chmod +x .githooks/post-commit .githooks/pre-push
cat <<'EOF'
✅ Claude sync hooks installed.
• post-commit: auto-syncs on dev-doc changes
• pre-push: warns if .claude-sync/ is stale
Run once to verify: bash scripts/sync-claude-docs.sh sync
EOF

308
scripts/sync-claude-docs.sh Executable file
View File

@@ -0,0 +1,308 @@
#!/usr/bin/env bash
set -euo pipefail
# Claude Project Knowledge sync tool.
# Keeps .claude-sync/ aligned with documents listed in .claude-sync.conf
# so the bundle can be uploaded to Claude Chat Project Knowledge.
err() { printf '%s\n' "$*" >&2; }
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "${REPO_ROOT}" ]; then
err "❌ Not inside a git repository."
exit 1
fi
cd "${REPO_ROOT}"
CONF_FILE=".claude-sync.conf"
OUT_DIR=".claude-sync"
MANIFEST="${OUT_DIR}/SYNC_MANIFEST.md"
sha256() {
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
elif command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
err "❌ Neither shasum nor sha256sum is available."
exit 1
fi
}
iso_now() {
# ISO-8601 with timezone offset; portable across macOS (BSD) and Linux (GNU).
date +"%Y-%m-%dT%H:%M:%S%z"
}
file_mtime() {
# Returns "YYYY-MM-DD HH:MM:SS" for the given file.
local f="$1"
if stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$f" >/dev/null 2>&1; then
stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$f"
else
stat -c "%y" "$f" | cut -d'.' -f1
fi
}
read_conf_paths() {
# Emits configured paths, skipping blank lines and # comments; trims whitespace.
if [ ! -f "${CONF_FILE}" ]; then
err "❌ Config file not found: ${CONF_FILE}"
exit 1
fi
awk '
{
sub(/#.*/, "")
gsub(/^[[:space:]]+|[[:space:]]+$/, "")
if (length($0) > 0) print
}
' "${CONF_FILE}"
}
cmd_sync() {
local paths=()
while IFS= read -r line; do
paths+=("${line}")
done < <(read_conf_paths)
if [ ${#paths[@]} -eq 0 ]; then
err "❌ No paths configured in ${CONF_FILE}."
exit 1
fi
# Verify every listed file exists.
local missing=()
local p
for p in "${paths[@]}"; do
if [ ! -f "${p}" ]; then
missing+=("${p}")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
err "❌ Missing source files listed in ${CONF_FILE}:"
for p in "${missing[@]}"; do
err " ${p}"
done
exit 1
fi
# Detect filename collisions (e.g. two README.md under different folders).
local seen_basenames=""
for p in "${paths[@]}"; do
local bn
bn="$(basename "${p}")"
# Find any prior path with the same basename.
local other=""
local q
for q in "${paths[@]}"; do
if [ "${q}" = "${p}" ]; then continue; fi
if [ "$(basename "${q}")" = "${bn}" ]; then
other="${q}"
break
fi
done
if [ -n "${other}" ]; then
case "${seen_basenames}" in
*"|${bn}|"*) ;;
*)
err "❌ Filename collision in ${CONF_FILE}: basename '${bn}' used by:"
err " ${p}"
err " ${other}"
exit 1
;;
esac
seen_basenames="${seen_basenames}|${bn}|"
fi
done
rm -rf "${OUT_DIR}"
mkdir -p "${OUT_DIR}"
local git_full_sha git_short_sha branch synced_at
git_full_sha="$(git rev-parse HEAD)"
git_short_sha="$(git rev-parse --short HEAD)"
branch="$(git rev-parse --abbrev-ref HEAD)"
synced_at="$(iso_now)"
# Build manifest rows as we go.
local manifest_rows=""
local count=0
for p in "${paths[@]}"; do
local bn hash mtime dest
bn="$(basename "${p}")"
hash="$(sha256 "${p}")"
mtime="$(file_mtime "${p}")"
dest="${OUT_DIR}/${bn}"
{
printf '%s\n' "<!-- CREWLI-SYNC"
printf 'source: %s\n' "${p}"
printf 'sha256: %s\n' "${hash}"
printf 'git-sha: %s\n' "${git_short_sha}"
printf 'synced-at: %s\n' "${synced_at}"
printf '%s\n' "-->"
printf '\n'
} > "${dest}"
cat "${p}" >> "${dest}"
manifest_rows="${manifest_rows}| ${bn} | ${p} | ${hash} | ${mtime} |"$'\n'
count=$((count + 1))
done
{
printf '%s\n' "# Crewli Project Knowledge Sync Manifest"
printf '\n'
printf '**Generated:** %s\n' "${synced_at}"
printf '**Git SHA:** %s (short: %s)\n' "${git_full_sha}" "${git_short_sha}"
printf '**Branch:** %s\n' "${branch}"
printf '**Files synced:** %d\n' "${count}"
printf '\n'
printf '%s\n' "## Upload instructions"
printf '\n'
printf '%s\n' "Upload every file in \`.claude-sync/\` — including this manifest — to the"
printf '%s\n' "Crewli Claude Project Knowledge, replacing existing versions. The manifest"
printf '%s\n' "is what Claude Chat uses for drift detection against the current HEAD."
printf '\n'
printf '%s\n' "## Drift-detection rule"
printf '\n'
printf '%s\n' "Re-sync is required when:"
printf '%s\n' "1. Current git HEAD SHA differs from this manifest's git SHA, AND"
printf '%s\n' "2. Any listed file's current SHA256 differs from the hash recorded below."
printf '\n'
printf '%s\n' "## File manifest"
printf '\n'
printf '%s\n' "| File | Source path | SHA256 | Source mtime |"
printf '%s\n' "|------|-------------|--------|--------------|"
printf '%s' "${manifest_rows}"
printf '\n'
printf '%s\n' "## Workflow note for Claude Chat"
printf '\n'
printf '%s\n' "When the user reports work that touched any listed file, verify the"
printf '%s\n' "manifest SHA above matches the latest HEAD they reference. If not,"
printf '%s\n' "require a re-sync before generating new prompts."
} > "${MANIFEST}"
err "✅ Claude sync complete — ${count} files ready in ${OUT_DIR}/"
err "📤 Upload to Claude Project Knowledge (replace existing):"
for p in "${paths[@]}"; do
err " ${OUT_DIR}/$(basename "${p}")"
done
err " ${MANIFEST}"
}
cmd_check() {
if [ ! -f "${MANIFEST}" ]; then
err "❌ No sync manifest found. Run: bash scripts/sync-claude-docs.sh sync"
exit 1
fi
local paths=()
while IFS= read -r line; do
paths+=("${line}")
done < <(read_conf_paths)
local drift=()
local missing=()
local p
for p in "${paths[@]}"; do
if [ ! -f "${p}" ]; then
missing+=("${p}")
continue
fi
# Extract recorded SHA from manifest row whose "Source path" column matches p.
local recorded
recorded="$(awk -F' *\\| *' -v src="${p}" '
$0 ~ /^\|/ && $3 == src { print $4; exit }
' "${MANIFEST}")"
if [ -z "${recorded}" ]; then
drift+=("${p} (not recorded in manifest)")
continue
fi
local current
current="$(sha256 "${p}")"
if [ "${current}" != "${recorded}" ]; then
drift+=("${p} (source changed since last sync)")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
err "⚠️ Out of sync:"
for p in "${missing[@]}"; do
err " ${p} (source file missing)"
done
fi
if [ ${#drift[@]} -gt 0 ]; then
if [ ${#missing[@]} -eq 0 ]; then
err "⚠️ Out of sync:"
fi
for p in "${drift[@]}"; do
err " ${p}"
done
err "Run: bash scripts/sync-claude-docs.sh sync"
exit 1
fi
if [ ${#missing[@]} -gt 0 ]; then
err "Run: bash scripts/sync-claude-docs.sh sync"
exit 1
fi
printf '%s\n' "✅ .claude-sync/ matches current dev-docs"
}
cmd_list() {
local paths=()
while IFS= read -r line; do
paths+=("${line}")
done < <(read_conf_paths)
local p marker
for p in "${paths[@]}"; do
if [ -f "${p}" ]; then
marker="✓"
else
marker="✗"
fi
printf ' %s %s\n' "${marker}" "${p}"
done
}
cmd_help() {
cat <<'EOF'
sync-claude-docs.sh — Crewli → Claude Project Knowledge sync
Usage:
bash scripts/sync-claude-docs.sh [subcommand]
Subcommands:
sync (default) Rebuild .claude-sync/ from .claude-sync.conf and write SYNC_MANIFEST.md
check Verify .claude-sync/ matches current source files; non-zero exit on drift
list Print configured source paths with existence markers (✓ / ✗)
help Show this help text
Examples:
bash scripts/sync-claude-docs.sh
bash scripts/sync-claude-docs.sh sync
bash scripts/sync-claude-docs.sh check
bash scripts/sync-claude-docs.sh list
Config: .claude-sync.conf (one path per line; # comments and blank lines OK)
Output: .claude-sync/ (git-ignored; regenerated on each sync)
EOF
}
sub="${1:-sync}"
case "${sub}" in
sync) cmd_sync ;;
check) cmd_check ;;
list) cmd_list ;;
help|-h|--help) cmd_help ;;
*)
err "❌ Unknown subcommand: ${sub}"
err ""
cmd_help >&2
exit 1
;;
esac