plugins/coordinator/skills/percolate/SKILL.md
Dry-run then confirm publish of files from a working source tree to a named publish-repo target. Wraps publish.sh with gate + CI smoke.
npx skillsauth add oduffy-delphi/coordinator-claude percolateInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Wraps the existing publish.sh + publish-repo CI gate into a single deterministic invocation: dry-run first, PM-confirm when changes are significant, real run, optional CI smoke, unified summary. Hook scripts registered under setup/percolate-hooks/<target>/{pre-rsync,post-rsync,pre-ci}/ run at the corresponding boundaries — this skill does not name specific hooks; it runs whatever's registered.
Announce at start: "Running /percolate <target> — dry-run → confirm → publish."
Use /percolate when:
publish-targets.sh).Do NOT use /percolate when:
bash ~/.claude/setup/publish.sh directly for multi-target.publish-targets.sh or add a new target — Branch 0 will walk setup automatically. Manual edit of publish-targets.sh also works.Run this branch before Step 1 on every invocation. It silently skips when the target is already fully configured; it walks setup when any piece is missing.
Gate check — all three conditions must be true to skip:
<target> argument is provided AND appears in setup/publish-targets.sh.<source_dir>/.percolate-ignore exists (resolve <source_dir> from the matching TARGETS entry's third pipe-separated field).setup/percolate-hooks/<target>/pre-rsync/, post-rsync/, and pre-ci/ all exist.# Gate check — source publish-targets.sh and test conditions
bash -c '
source setup/publish-targets.sh 2>/dev/null || { echo "MISSING_TARGETS"; exit 0; }
TARGET="<target>"
src_dir=""
for t in "${TARGETS[@]}"; do
IFS="|" read -r name mode src dest <<< "$t"
if [[ "$name" == "$TARGET" ]]; then src_dir="$src"; break; fi
done
[[ -z "$src_dir" ]] && { echo "MISSING_TARGET_ENTRY"; exit 0; }
[[ ! -f "${src_dir}/.percolate-ignore" ]] && { echo "MISSING_IGNORE"; exit 0; }
for hp in pre-rsync post-rsync pre-ci; do
[[ ! -d "setup/percolate-hooks/$TARGET/$hp" ]] && { echo "MISSING_HOOK_DIR:$hp"; exit 0; }
done
echo "CONFIGURED"
'
On CONFIGURED: silent skip — proceed directly to Step 1 (Pre-Flight).
On any other output: walk docs/wiki/percolate-setup.md (plugin-relative path) inline, following Steps 1–5 of that procedure. After the setup procedure completes, continue to Step 1 below.
The setup wiki is the single source of truth for the interactive procedure (target registration, .percolate-ignore audit-and-classify, grey-zone AskUserQuestion, hook scaffolding, and drift detection). Do not duplicate its steps here — walk it inline.
Source ~/.claude/setup/publish-targets.sh in a sub-shell and extract all registered target names:
bash -c "
source ~/.claude/setup/publish-targets.sh
for t in \"\${TARGETS[@]}\"; do
IFS='|' read -r name rest <<< \"\$t\"
echo \"\$name\"
done
"
If <target> is not in the list, print the registered targets and exit non-zero. Do not proceed.
Error: target '<target>' is not registered in publish-targets.sh.
Registered targets:
<target-a>
<target-b>
...
Execute the dry-run and capture stdout + exit code. Pass output through to console.
bash ~/.claude/setup/publish.sh --dry-run <target>
If exit code is non-zero, jump to Step 7 (failure stop).
Parse the dry-run stdout to determine:
deleting or del. in rsync summary).CLAUDE.md, settings.json, files under hooks/, files under agents/..percolate-ignore policy state: if the dry-run output contains No .percolate-ignore found at <source>, surface this as a non-blocking nudge to the PM: ".percolate-ignore is missing — currently publishing everything. Re-run /percolate <target> — Branch 0 will detect and walk the setup wiki." The publish proceeds normally regardless; the nudge is informational.After the dry-run, resolve <source_dir> from the matching TARGETS entry's third pipe-separated field, then list source files newer than .percolate-ignore:
find "<source_dir>" -type f -newer "<source_dir>/.percolate-ignore" 2>/dev/null | head -20
(find -newer is portable across BSD and GNU; no stat -c %Y needed. If the ignore file is missing, this yields nothing — coverage-drift is silent until the file exists.)
If the result is non-empty, surface under a "Coverage drift since policy last reviewed:" panel. If empty, skip the panel entirely (no noise on a quiet run).
For a user-visible "last reviewed" date, optionally prepend:
date -r "<source_dir>/.percolate-ignore" '+%Y-%m-%d' 2>/dev/null
(BSD/GNU compatible — works on macOS and Linux.)
The panel is informational. PM reviews and either decides "yes these should publish" (no action) or "these should be denied" (manually edit .percolate-ignore). /percolate does NOT auto-add patterns.
Above the Step 3 confirmation gate, render a structured framing of the dry-run scope:
Impact radius:
Top directories: <dir1> (N), <dir2> (N), ... (top 5 by file count)
File types: md=N, sh=N, py=N, other=N
Sensitive paths: CLAUDE.md, settings.json, hooks/, agents/ [or: (none)]
Compute by parsing the dry-run stdout lines (UPDATE: <path>, NEW: <path>):
coordinator/skills, coordinator/docs/wiki); emit top 5..md, .sh, .py, everything else as other.CLAUDE.md, settings.json, hooks/*, agents/*. List unique hits; if none, render (none).This panel renders in EVERY dry-run (including no-op runs — empty values render as (none)). The point is to make impact visible BEYOND the mechanical file list — answer "what's about to ship and is any of it inappropriate?" at a glance.
.percolate-ignore is a STRUCTURAL filter (categories of paths). It cannot catch CONTENT leaks that accumulate through normal authoring: a name slipping into a wiki body, a peer-repo reference embedded in a snippet, a machine name in a code comment, a token pasted into an example. Those need a per-publish scan.
This step runs on EVERY /percolate invocation, not opt-in. It is fast (bounded grep over the about-to-publish file set), deterministic, and emits a structured panel that feeds the Step 3 gate.
Build the file set: parse the dry-run stdout for UPDATE: <path> and NEW: <path> lines (rel-paths from rsync). Resolve each to absolute via <source_dir>/<rel_path>. The scan grep targets only these files — not the full source tree.
Run the scan in three severity tiers. Each tier's regex set is pre-defined; do NOT skip any tier.
# Build the file list from dry-run stdout — example.
grep -E '^\s*(UPDATE|NEW):' "$DRYRUN_LOG" | awk '{print $2}' | \
while read -r rel; do echo "<source_dir>/$rel"; done > /tmp/percolate-scan-files.txt
# Tier HIGH — credential / secret shapes. Blocks publish on any hit.
xargs -a /tmp/percolate-scan-files.txt -d'\n' grep -nIE \
"(sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|AKIA[A-Z0-9]{16}|xox[bpars]-[A-Za-z0-9-]{10,}|ya29\.[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]+PRIVATE KEY-----)" \
2>/dev/null
# Tier MEDIUM — PM/EM identity, internal paths, peer-repo names. Surfaces to PM gate.
xargs -a /tmp/percolate-scan-files.txt -d'\n' grep -nIE \
"([Dd][óo]nal\\b|O'?[Dd]uffy|\\boduffy\\b|delphiinteractive|\\bstriker\\b|/c/Users/oduffy|~/\\.claude/(tasks|projects|memory|plans)/|/x/[a-z-]+|[XxCc]:/[a-z-]+|@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b)" \
2>/dev/null
# Tier LOW — informational only. Renders in panel without forcing gate.
# 40-char hex commit SHAs (excluding lockfile contexts), "First Officer" outside doctrine files.
xargs -a /tmp/percolate-scan-files.txt -d'\n' grep -nIE \
"(\\b[0-9a-f]{40}\\b|First Officer Doctrine)" \
2>/dev/null
Peer-repo name extension: if ~/.claude/state/repo-registry.md exists, extract the registered repo names (one regex-quoted alternation). Add to MEDIUM tier as a fourth alternation — peer-repo names should not appear verbatim in publish content unless the publish target IS that repo.
if [[ -f "$HOME/.claude/state/repo-registry.md" ]]; then
# Extract repo names; format depends on registry schema (see docs/wiki/repo-registry.md)
PEER_REPOS=$(awk '/^- name:/ {print $3}' "$HOME/.claude/state/repo-registry.md" | \
grep -v "^$(<target>)$" | paste -sd'|' -)
if [[ -n "$PEER_REPOS" ]]; then
xargs -a /tmp/percolate-scan-files.txt -d'\n' grep -nIE "\\b($PEER_REPOS)\\b" 2>/dev/null
fi
fi
Render the panel above the Step 3 gate:
Content-leakage scan:
HIGH (credential/secret shapes — BLOCKS publish):
<file>:<line>: <verbatim line, secret token redacted to first 4 + ellipsis>
[or: (none)]
MEDIUM (identity / internal paths / peer-repo names — surfaces to gate):
<file>:<line>: <verbatim line>
[or: (none)]
LOW (informational — commit SHAs, doctrine language):
N hits across M files [or: (none)]
Severity behaviour:
Hook escape: if a <source_dir>/.percolate-scan-allowlist file exists, treat each line as a file:line exemption (e.g. for a wiki that legitimately documents an example secret format). The exemption MUST be the exact file:line; pattern matches don't auto-allowlist. Exemptions are reviewed during percolation setup (see docs/wiki/percolate-setup.md § Drift Detection), not here.
False-positive caveat: the regex set is intentionally broad. the PM matches any first-name use (intended). Refining is the EM's call when integrating findings — but defaulting to "surface and let PM judge" beats "silently miss a real leak."
The publish repo can accumulate commits the source doesn't have: another EM on the machine may hand-fix a bug directly in dest, a release-time edit may land there first, or a previous percolate cycle may have been followed by ad-hoc patching. Overwriting those commits silently regresses real fixes. This step surfaces them BEFORE the gate.
Anchor resolution. publish.sh writes a marker on every successful real run at ~/.claude/setup/percolate-state/<target>.lastsync containing the dest HEAD SHA at sync time. Read it; the contents are the <since> ref.
marker="$HOME/.claude/setup/percolate-state/<target>.lastsync"
if [[ -f "$marker" ]]; then
since_ref="$(cat "$marker")"
anchor_mode="marker"
else
since_ref="" # fall back to 30-day window
anchor_mode="30day-fallback"
fi
Build the rel-path filter from the dry-run stdout (same set as Step 2c — files about to be overwritten). Resolve relative to dest root (the 4th pipe-separated field of the matching TARGETS entry).
Run git log in dest scoped to those paths:
cd "<dest>"
if [[ "$anchor_mode" == "marker" ]]; then
# Validate the marker SHA still exists in dest (could have been force-pushed/rebased)
if git rev-parse --verify "$since_ref" &>/dev/null; then
git log --no-merges --format='%h %ad %s' --date=short \
"$since_ref..HEAD" -- $(cat /tmp/percolate-scan-files.txt | sed "s|^<dest>/||")
else
anchor_mode="marker-stale"
git log --no-merges --since='30 days ago' --format='%h %ad %s' --date=short \
-- $(cat /tmp/percolate-scan-files.txt | sed "s|^<dest>/||")
fi
else
git log --no-merges --since='30 days ago' --format='%h %ad %s' --date=short \
-- $(cat /tmp/percolate-scan-files.txt | sed "s|^<dest>/||")
fi
Render the panel above the Step 3 gate when output is non-empty:
Inverse drift — dest commits touching files about to be overwritten:
anchor: <marker SHA> [or: 30-day fallback (no marker)] [or: marker-stale (SHA not in dest history)]
<abbrev-sha> <date> <subject>
<abbrev-sha> <date> <subject>
...
→ Read each commit's diff before proceeding. If it's a real fix, back-port to source FIRST,
then re-run /percolate. Confirming below will OVERWRITE these changes.
If output is empty, skip the panel entirely.
Dismiss two structural false positives before alarming — neither is lost work: a CRLF-only commit (source is CRLF, dest is LF-normalized — verify with diff --strip-trailing-cr <dest-file> <source-file>; empty = no content change), and a release/version landing (a prior percolation echo — source is the origin and has since moved ahead = forward drift). Only a non-CRLF, non-release change authored directly in dest is real drift. Never use a raw diff <dest> <source> as the signal — dest content has passed through publish-time-transform.sh (identity scrub) and so always differs; the git-log-since-marker above is the reliable signal.
Gate behaviour: ≥1 real (non-CRLF, non-release) inverse-drift commit forces the Step 3 gate to fire (same severity as MEDIUM content-leak), and the gate prompt notes the count. Does NOT auto-abort — PM decides whether to back-port first or proceed.
Marker-stale caveat: if the stored SHA no longer exists in dest history (force-push, rebase, repo reinit), the 30-day fallback runs and anchor_mode: marker-stale renders. PM should re-percolate to refresh the anchor afterward.
First-run caveat: on the very first /percolate after this step ships, no marker exists — 30-day fallback runs once and may be noisy. Subsequent runs are anchored precisely.
Gate fires iff any of:
CLAUDE.md, settings.json, hooks/, agents/).Zero-changes case: if dry-run reports no files to transfer ("sending incremental file list" with no file entries, or rsync reports 0 files), skip the gate AND Step 4. Proceed directly to Step 5. The Step 6 summary reports real-run: skipped (no-op).
Gate prompt format:
Dry-run summary for target '<target>':
added: N
modified: N
deleted: N
First 10 paths:
<path>
<path>
... (N more)
Proceed with real publish? [y/N]
Wait for PM confirmation. On anything other than y / yes, exit 0 with "Publish cancelled."
When gate does NOT fire: proceed to Step 4 without prompting.
Execute the real publish and capture stdout + exit code. Pass all output through to console.
bash ~/.claude/setup/publish.sh <target>
If exit code is non-zero, jump to Step 7 (failure stop).
Scan stdout for lines containing REVIEW WARNING. If any are found, surface them verbatim to the PM:
Phase 4 audit found REVIEW items — acknowledge before next publish:
WARNING: REVIEW ...
These warnings are advisory (non-blocking); the publish succeeded.
Resolve the destination path by sourcing publish-targets.sh and reading the 4th pipe-separated field of the matching TARGETS entry:
bash -c '
source ~/.claude/setup/publish-targets.sh
for t in "${TARGETS[@]}"; do
IFS="|" read -r name mode src dest <<< "$t"
if [[ "$name" == "<target>" ]]; then
echo "$dest"
exit 0
fi
done
exit 1
'
(Match-and-exit pattern: the loop returns exit 0 only when the target is found and its dest path emitted on stdout. Non-matching iterations would otherwise set the loop's last-evaluated exit code to 1, even with the right stdout — that breaks && chains in the calling step.)
Before running run-all-checks.py, discover and invoke any registered pre-ci hooks for this target. Hooks live at ~/.claude/setup/percolate-hooks/<target>/pre-ci/*.sh (lexical order, numeric prefixes order execution). Each hook receives <dest> as $1. Non-zero exit aborts; jump to Step 7.
hooks_dir="$HOME/.claude/setup/percolate-hooks/<target>/pre-ci"
if [[ -d "$hooks_dir" ]]; then
for hook in "$hooks_dir"/*.sh; do
[[ -e "$hook" ]] || continue # nullglob guard
echo " → pre-ci/$(basename "$hook")"
bash "$hook" "<dest>" </dev/null || exit 1
done
fi
If no pre-ci directory exists or it's empty, skip silently.
If <dest>/.github/scripts/run-all-checks.py exists, run CI with cwd at the repo root:
cd "<dest>" && python .github/scripts/run-all-checks.py
Capture exit code. Surface full output to console. If the script does not exist, skip silently — not every target has CI.
If exit code is non-zero, jump to Step 7 (failure stop).
Print a 4-line summary regardless of run path:
/percolate <target> — <VERDICT>
dry-run: exit <N> (<file-count> files)
real-run: exit <N> [or: skipped (no-op)]
ci-smoke: exit <N> [or: n/a (no run-all-checks.py)]
Verdict tiers:
When any step fails, print:
bash or python command the PM can re-run by hand.Example:
Step 4 failed — real run exited 1.
stderr:
rsync: [sender] read error: Connection reset by peer (104)
Manual recovery:
bash ~/.claude/setup/publish.sh <target>
Do not continue to subsequent steps after a failure.
Post-rsync hook failure → torn-write recovery: If a post-rsync hook (e.g. 10-depersonalize.sh) failed mid-way, the destination is partially mutated — some files synced and post-processed, others synced but un-processed. Recovery: fix the hook, re-invoke /percolate <target>. The sync is idempotent (rsync re-applies unchanged files), and post-rsync hooks must be re-runnable (depersonalize already is — --check + --fix is idempotent). Do NOT panic and revert the destination — re-running is the correct path.
Pre-ci hook failure → CI not run, destination consistent: Same destination state as a successful publish; only CI smoke was skipped. Re-invoke /percolate <target> Step 5 manually (run-all-checks.py) after fixing the hook to retry CI.
publish.sh discovers setup/percolate-hooks/<target>/<hook-point>/*.sh by convention and runs whatever's registered.publish.sh, publish-targets.sh, or any source file — invocation orchestration only.publish-targets.sh to "fix" a missing target — exits with the target list instead./percolate with no argument exits with the registered target list — same as an unknown target.git add / commit / push in the publish repo manually or via a separate workflow.tools
Orient session — preflight, load context, choose work
documentation
Wrap up finished work — capture lessons, update docs
testing
Use before commit, /merge-to-main, /workday-complete, or to validate repo state. Resolves and runs the project's configured fast-test command.
development
Root-cause discipline for ONE identified bug, test failure, or unexpected behavior — pin the premise, reproduce, trace to source, fix at source, verify. For a single known issue, not a codebase sweep.