Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,10 @@ The `.cursor/` directory contains Cursor-specific configurations that are mainta
git clone https://github.com/cheshirecode/dotfiles.git ~/.dotfiles
cd ~/.dotfiles && bin/install.sh

# bin/install.sh is the SUPPORTED install path: detects your OS, installs
# runtime deps, runs the agent-skill installer (with the rmtree-safety
# sentinel), wires hooks, runs bin/doctor.sh. Idempotent — re-run is safe.
# bin/install.sh is the SUPPORTED install path: detects your OS, installs
# runtime deps, installs every manifest skill for Claude Code, Codex, and
# Cursor (with the rmtree-safety sentinel), wires hooks, and runs
# bin/doctor.sh. Idempotent — re-run is safe.
#
# If you want manual symlinks instead, BACK UP FIRST. `ln -sf` will
# silently overwrite an existing real ~/.bashrc / ~/.zshrc / ~/.gitconfig
Expand Down
21 changes: 13 additions & 8 deletions bin/doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# 1. Runtime deps on PATH (python3, gh, git, rg, jq, direnv)
# 2. Python ≥ 3.10 (worklog lint helpers depend on 3.10+ syntax)
# 3. PyYAML importable (install-skills.sh uses it)
# 4. Each manifest skill present as a SKILL.md under ~/.claude/skills/
# 4. Each manifest skill present under Claude, shared-agent, and Cursor roots
# 5. _worklog repo present and on a clean HEAD
# 6. Hooks wired (.claude/settings.json mentions autosave)
# 7. gh auth (warn-only — works for unauth'd public-repo flows)
Expand All @@ -21,7 +21,10 @@ warn() { say "WARN" "$1"; WARN=$((WARN+1)); }

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MANIFEST="$REPO_ROOT/manifest/skills.yaml"
SKILLS_DIR="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}"
CLAUDE_SKILLS_DIR="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}"
AGENT_SKILLS_DIR="${AGENT_SKILLS_DIR:-$HOME/.agents/skills}"
CURSOR_SKILLS_DIR="${CURSOR_SKILLS_DIR:-$HOME/.cursor/skills}"
SKILL_ROOTS=("$CLAUDE_SKILLS_DIR" "$AGENT_SKILLS_DIR" "$CURSOR_SKILLS_DIR")
PROJECTS_DIR="${PROJECTS_DIR:-$HOME/Documents/projects}"

echo "doctor: runtime deps"
Expand Down Expand Up @@ -55,12 +58,14 @@ print('\n'.join(s['name'] for s in yaml.safe_load(open('$MANIFEST'))['skills']))
rm -f "$skill_list_err"
while IFS= read -r name; do
[[ -z "$name" ]] && continue
skill_md="$SKILLS_DIR/$name/SKILL.md"
if [[ -f "$skill_md" ]]; then
ok "$name → $skill_md"
else
warn "$name SKILL.md missing (run bin/install-skills.sh)"
fi
for skill_root in "${SKILL_ROOTS[@]}"; do
skill_md="$skill_root/$name/SKILL.md"
if [[ -f "$skill_md" ]]; then
ok "$name → $skill_md"
else
warn "$name SKILL.md missing at $skill_root (run bin/install-skills.sh)"
fi
done
done <<< "$skill_list"
else
fail "manifest/skills.yaml missing"
Expand Down
97 changes: 56 additions & 41 deletions bin/install-skills.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
#!/usr/bin/env bash
# Install agent skills from manifest/skills.yaml into ~/.claude/skills/.
# Install agent skills from manifest/skills.yaml into all supported user roots:
# ~/.claude/skills/ for Claude Code, ~/.agents/skills/ for Codex and shared
# discovery, and ~/.cursor/skills/ for Cursor's native discovery surface.
#
# Two source types:
# - subpath: copy a directory from this repo (vendored skill).
# - git: clone a repo at a pinned SHA, then symlink (Mac/Linux) or
# copy (WSL fallback) into ~/.claude/skills/<name>/.
# copy (WSL fallback) into all three user skill roots.
#
# Mac/Linux uses symlinks (cheap, easy upgrade). WSL2 inherits Linux behavior.
# Windows-native is unsupported — install.sh refuses earlier.
Expand Down Expand Up @@ -42,23 +44,27 @@ MANIFEST="$REPO_ROOT/manifest/skills.yaml"
[[ -f "$MANIFEST" ]] || { echo "install-skills: manifest not found at $MANIFEST" >&2; exit 1; }

SKILLS_DIR="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}"
CACHE_DIR="${CLAUDE_AGENT_CACHE:-$HOME/.agents/skills}"
SHARED_SKILLS_DIR="${AGENT_SKILLS_DIR:-$HOME/.agents/skills}"
CURSOR_SKILLS_DIR="${CURSOR_SKILLS_DIR:-$HOME/.cursor/skills}"
SOURCE_CACHE_DIR="${CLAUDE_AGENT_CACHE:-$HOME/.cache/dotfiles-agent-skills}"

mkdir -p "$SKILLS_DIR" "$CACHE_DIR"
mkdir -p "$SKILLS_DIR" "$SHARED_SKILLS_DIR" "$CURSOR_SKILLS_DIR" "$SOURCE_CACHE_DIR"

# Parse manifest via Python (yaml is stdlib-adjacent; safer than awk on YAML).
python3 - "$MANIFEST" "$SINGLE" "$DRY_RUN" "$SKILLS_DIR" "$CACHE_DIR" "$REPO_ROOT" <<'PY'
python3 - "$MANIFEST" "$SINGLE" "$DRY_RUN" "$SKILLS_DIR" "$SHARED_SKILLS_DIR" "$CURSOR_SKILLS_DIR" "$SOURCE_CACHE_DIR" "$REPO_ROOT" <<'PY'
import os, sys, shutil, subprocess, pathlib
try:
import yaml
except ImportError:
sys.stderr.write("install-skills: PyYAML not installed. Run: pip3 install --user pyyaml\n")
sys.exit(1)

manifest_path, single, dry_run, skills_dir, cache_dir, repo_root = sys.argv[1:7]
manifest_path, single, dry_run, skills_dir, shared_skills_dir, cursor_skills_dir, source_cache_dir, repo_root = sys.argv[1:9]
dry = dry_run == "1"
skills_dir = pathlib.Path(skills_dir).expanduser()
cache_dir = pathlib.Path(cache_dir).expanduser()
shared_skills_dir = pathlib.Path(shared_skills_dir).expanduser()
cursor_skills_dir = pathlib.Path(cursor_skills_dir).expanduser()
source_cache_dir = pathlib.Path(source_cache_dir).expanduser()
repo_root = pathlib.Path(repo_root)

m = yaml.safe_load(open(manifest_path))
Expand Down Expand Up @@ -122,9 +128,34 @@ def write_sentinel(dst, source_info):
"""Write a sentinel so future install-skills runs recognize this dir."""
(dst / SENTINEL).write_text(source_info + "\n")

def install_destinations(entry):
"""Preserve the manifest destination and add shared and Cursor roots."""
destinations = [
pathlib.Path(entry["install_to"]).expanduser(),
shared_skills_dir / entry["name"],
cursor_skills_dir / entry["name"],
]
return list(dict.fromkeys(destinations))

def link_or_copy(src, dst, name, source_info):
if dst.is_symlink() or dst.exists():
print(f" refresh {name}: {dst}")
if not dry:
if dst.is_symlink(): dst.unlink()
elif dst.is_dir(): shutil.rmtree(dst)
else: dst.unlink()
else:
print(f" install {name}: {dst}")
if not dry:
dst.parent.mkdir(parents=True, exist_ok=True)
try:
os.symlink(src.resolve(), dst)
except OSError:
shutil.copytree(src, dst)
write_sentinel(dst, source_info)

def install_subpath(entry):
src = repo_root / entry["source"]["path"]
dst = pathlib.Path(entry["install_to"]).expanduser()
if not src.exists():
if entry.get("optional") is True:
print(f" SKIP optional {entry['name']}: source {src} not present")
Expand All @@ -141,23 +172,16 @@ def install_subpath(entry):
if err:
sys.stderr.write(f"install-skills: refusing {entry['name']}: {err}\n")
sys.exit(3)
refuse_if_unowned(dst, entry["name"])
if dst.is_symlink() or dst.exists():
print(f" refresh {entry['name']}: {dst}")
if not dry:
if dst.is_symlink(): dst.unlink()
elif dst.is_dir(): shutil.rmtree(dst)
else: dst.unlink()
else:
print(f" install {entry['name']}: {dst}")
if not dry:
dst.parent.mkdir(parents=True, exist_ok=True)
try:
os.symlink(src.resolve(), dst)
except OSError:
# WSL or filesystems without symlink support — fall back to copy.
shutil.copytree(src, dst)
write_sentinel(dst, f"subpath:{entry['source']['path']}")
destinations = install_destinations(entry)
for dst in destinations:
refuse_if_unowned(dst, entry["name"])
for dst in destinations:
link_or_copy(
src,
dst,
entry["name"],
f"subpath:{entry['source']['path']}",
)
return True

def install_git(entry):
Expand All @@ -174,7 +198,7 @@ def install_git(entry):
f" Override with INSTALL_SKILLS_ALLOW_MOVING_REF=1 if you really mean it.\n"
)
sys.exit(3)
cache = cache_dir / name
cache = source_cache_dir / name
# Council guardrail #10: atomic clone-or-swap on git operations.
# Mid-fetch network failure must not leave the cache dir in a half-state.
import tempfile as _tempfile
Expand All @@ -183,7 +207,7 @@ def install_git(entry):
# there's no half-populated cache dir to confuse the next run.
print(f" clone {name}: {repo} → {cache}")
if not dry:
staging = pathlib.Path(_tempfile.mkdtemp(prefix=f".{name}-staging-", dir=cache_dir))
staging = pathlib.Path(_tempfile.mkdtemp(prefix=f".{name}-staging-", dir=source_cache_dir))
shutil.rmtree(staging) # mkdtemp made it; git clone wants it absent
try:
run(["git", "clone", "--quiet", f"https://github.com/{repo}.git", str(staging)])
Expand All @@ -201,20 +225,11 @@ def install_git(entry):
print(f" upgrade {name}: {cache} → {ref[:12]}")
run(["git", "-C", str(cache), "fetch", "--quiet", "origin"])
run(["git", "-C", str(cache), "checkout", "--quiet", ref])
dst = pathlib.Path(entry["install_to"]).expanduser()
refuse_if_unowned(dst, name)
if dst.is_symlink() or dst.exists():
if not dry:
if dst.is_symlink(): dst.unlink()
elif dst.is_dir(): shutil.rmtree(dst)
else: dst.unlink()
if not dry:
dst.parent.mkdir(parents=True, exist_ok=True)
try:
os.symlink(cache.resolve(), dst)
except OSError:
shutil.copytree(cache, dst)
write_sentinel(dst, f"git:{repo}@{ref}")
destinations = install_destinations(entry)
for dst in destinations:
refuse_if_unowned(dst, name)
for dst in destinations:
link_or_copy(cache, dst, name, f"git:{repo}@{ref}")
return True

installed = skipped = 0
Expand Down
28 changes: 15 additions & 13 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,20 +61,22 @@ if [ -d "$REPO_DIR/.cursor" ]; then
cp -R "$REPO_DIR/.cursor/." "$DEST/.cursor/"
fi

# Symlink Claude Code skills shipped in this repo into ~/.claude/skills/.
# Code (skill + bin) is version-controlled here; per-machine data/config
# (e.g. worklog's WORKLOG_REPO) lives outside the repo via ~/.shell_common.local.
# Symlink every skill shipped in this repo into the shared Agent Skills root,
# Claude Code's personal skill root, and Cursor's native personal skill root.
# Code stays version-controlled here; per-machine data/config lives outside.
if [ -d "$REPO_DIR/skills" ]; then
mkdir -p "$DEST/.claude/skills"
for skill in "$REPO_DIR"/skills/*/; do
sname="$(basename "$skill")"
starget="$DEST/.claude/skills/$sname"
if [ -L "$starget" ] && [ "$(readlink "$starget")" = "${skill%/}" ]; then
continue
fi
backup "$starget"
echo "Symlinking skill $sname into $starget..."
ln -s "${skill%/}" "$starget"
for skills_root in "$DEST/.agents/skills" "$DEST/.claude/skills" "$DEST/.cursor/skills"; do
mkdir -p "$skills_root"
for skill in "$REPO_DIR"/skills/*/; do
sname="$(basename "$skill")"
starget="$skills_root/$sname"
if [ -L "$starget" ] && [ "$(readlink "$starget")" = "${skill%/}" ]; then
continue
fi
backup "$starget"
echo "Symlinking skill $sname into $starget..."
ln -s "${skill%/}" "$starget"
done
done
fi

Expand Down
20 changes: 18 additions & 2 deletions manifest/skills.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
# addressed). Bump SHAs explicitly when upgrading; don't track moving branches.
#
# Targets:
# user-installed Claude Code skills under ~/.claude/skills/<name>/
# sourced from public GitHub repos.
# every manifest skill is installed under ~/.claude/skills/<name>/,
# ~/.agents/skills/<name>/, and ~/.cursor/skills/<name>/.
#
# Skills that ship with Claude Code itself (init, review, security-review,
# claude-api, loop, schedule, statusline-setup, update-config, etc.) are
Expand Down Expand Up @@ -37,6 +37,22 @@ skills:
# installs the skill files; bin/install-worklog.sh clones the repo +
# wires hooks.

- name: loop-engineering
description: Bounded, evidence-driven loops with worklog-backed context and handoffs.
source:
type: subpath
ref: HEAD
path: skills/loop-engineering
install_to: ~/.claude/skills/loop-engineering

- name: evidence-gate
description: Require typed evidence coverage for every completion criterion.
source:
type: subpath
ref: HEAD
path: skills/evidence-gate
install_to: ~/.claude/skills/evidence-gate

- name: ship-hygiene
description: Pre-handoff sweep across worklog + PRs + CI.
source:
Expand Down
54 changes: 54 additions & 0 deletions skills/evidence-gate/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
name: evidence-gate
description: Gate completion claims by mapping every observable goal or acceptance criterion to typed command, artifact, Git, GitHub, or URL evidence. Use before marking a multi-clause task, agent loop, deployment, PR, or verification workflow complete, especially when tests passing does not prove delivery, merge, or user-visible success.
---

# Evidence Gate

Require evidence coverage, not a persuasive completion summary. The script
checks that every declared criterion has at least one typed evidence record; the
agent remains responsible for verifying that each record is truthful and
relevant.

## Declare every goal clause

Resolve `<skill-dir>` as this `SKILL.md` file's directory. Use stable lowercase
criterion IDs and an explicit gate path.

```bash
python3 <skill-dir>/scripts/evidence_gate.py init \
--gate <gate-file> \
--goal "<full observable goal>" \
--criterion "tests=full suite passes" \
--criterion "merge=PR is merged into main"
```

Do not collapse distinct outcomes into one criterion. Tests, commit, deployment,
PR state, and user-visible behavior are separate when the goal names them.

## Record verified evidence

After checking the source, attach evidence to exactly one criterion:

```bash
python3 <skill-dir>/scripts/evidence_gate.py record \
--gate <gate-file> \
--criterion tests \
--kind command \
--ref "tests/run.sh all" \
--result "62 pass, 0 fail"
```

Kinds are `command`, `artifact`, `git`, `github`, and `url`. Never record model
prose as evidence. For GitHub completion, inspect the exact PR head, base,
state, non-empty diff, and merged target before recording it.

## Gate completion

Run `check --gate <gate-file>`. Exit `1` means criteria remain uncovered; do not
claim completion. Exit `0` returns a `verification` value containing the
gate-file path and SHA-256 digest. Pass that value to the parent workflow's
completion record.

The digest proves which coverage artifact was checked, not that an evidence
source was interpreted correctly. Preserve the gate file with the task evidence.
4 changes: 4 additions & 0 deletions skills/evidence-gate/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
interface:
display_name: "Evidence Gate"
short_description: "Require evidence for every completion criterion"
default_prompt: "Use $evidence-gate to verify that every completion criterion has tool or artifact evidence."
Loading
Loading