Dry-run then confirm publish of coordinator plugin files to a named publish-repo target. Wraps publish.sh with gate + CI smoke.
Install via CLI
openskills install dbc-oduffy/coordinator-claude---
name: percolate
description: Dry-run then confirm publish of coordinator plugin files to a named publish-repo target. Wraps publish.sh with gate + CI smoke.
triggers:
- /percolate
- percolate
- publish to coordinator-claude
- push to publish repo
- sync meta to publish
- ship plugin updates
argument-hint: "<target>"
version: 1.0.0
---
# /percolate — Publish Plugin Files to a Publish-Repo Target
<!-- spec backlink: docs/plans/2026-05-08-formalize-percolation-spec2-push-to-publish-skill.md -->
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."
## When to Use / When NOT to Use
**Use `/percolate` when:**
- Publishing coordinator plugin files to a registered publish-repo target (e.g., `coordinator-claude`, `deep-research-claude`, `holodeck`).
- You want a dry-run diff + PM-confirm gate before any real rsync.
- You want CI smoke to run automatically after publish.
**Do NOT use `/percolate` when:**
- Publishing ALL targets at once — run `bash ~/.claude/setup/publish.sh` directly for multi-target.
- You need to edit `publish-targets.sh` or add a new target — Branch 0 will walk setup automatically. Manual edit of `publish-targets.sh` also works.
- You want to commit or push changes in the publish repo — this skill does not manage the publish repo's git state.
## Step Sequence
### Branch 0 — First-Run Setup (idempotent gate)
**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:**
1. `<target>` argument is provided AND appears in `setup/publish-targets.sh`.
2. `<source_dir>/.percolate-ignore` exists (resolve `<source_dir>` from the matching TARGETS entry's third pipe-separated field).
3. Hook directories `setup/percolate-hooks/<target>/pre-rsync/`, `post-rsync/`, and `pre-ci/` all exist.
```bash
# 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.
---
### Step 1 — Pre-Flight: Verify Target Exists
Source `~/.claude/setup/publish-targets.sh` in a sub-shell and extract all registered target names:
```bash
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:
coordinator-claude
deep-research-claude
holodeck
```
### Step 2 — Dry Run
Execute the dry-run and capture stdout + exit code. Pass output through to console.
```bash
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:
- Whether any deletions are present (lines matching `deleting` or `del.` in rsync summary).
- Total file count touched (lines not prefixed with a directory marker).
- Whether any sensitive paths are touched: `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.
#### Step 2a — Coverage-drift detection
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`:
```bash
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:
```bash
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.
#### Step 2b — Impact-radius gut-check
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>`):
- **Top directories:** strip filename, count occurrences of each top-level + second-level directory (e.g. `coordinator/skills`, `coordinator/docs/wiki`); emit top 5.
- **File types:** group by extension `.md`, `.sh`, `.py`, everything else as `other`.
- **Sensitive paths:** match each path against `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.
#### Step 2c — Content-leakage scan
`.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.
```bash
# 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/tasks/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.
```bash
if [[ -f "$HOME/.claude/tasks/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/tasks/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:**
- HIGH ≥1: print panel, abort with non-zero exit, do NOT proceed to Step 3 gate. Direct PM to fix the leak in source before re-running.
- MEDIUM ≥1: panel rendered, gate FORCED to fire (PM confirmation required even if file count <10), confirmation prompt notes the MEDIUM count.
- LOW ≥1 OR all clean: panel rendered for transparency; does not change gate-fire logic.
**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."
#### Step 2d — Inverse-drift detection
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.
```bash
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:
```bash
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.
**Gate behaviour:** ≥1 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.
### Step 3 — PM Confirmation Gate
**Gate fires iff any of:**
- Dry-run output contains a deletion.
- Dry-run touches ≥10 files.
- Dry-run touches a sensitive path (`CLAUDE.md`, `settings.json`, `hooks/`, `agents/`).
- Step 2c content-leakage scan reported ≥1 MEDIUM hit (PM/EM identity, internal path, peer-repo name). HIGH hits aborted before reaching this step; MEDIUM hits force the gate even if all other conditions are absent.
- Step 2d inverse-drift detection reported ≥1 dest commit touching files about to be overwritten.
**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.
### Step 4 — Real Run
Execute the real publish and capture stdout + exit code. Pass all output through to console.
```bash
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.
### Step 5 — Optional CI Smoke
Resolve the destination path by sourcing `publish-targets.sh` and reading the 4th pipe-separated field of the matching TARGETS entry:
```bash
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.)
#### Step 5a — Pre-CI hooks
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.
```bash
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.
#### Step 5b — CI smoke
If `<dest>/.github/scripts/run-all-checks.py` exists, run CI with cwd at the repo root:
```bash
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).
### Step 6 — Unified Summary
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:**
- **PASS** — all exits 0 AND no Phase 4 REVIEW lines surfaced.
- **PASS-WITH-WARNINGS** — all exits 0 AND Phase 4 REVIEW lines were present. The EM should acknowledge before the next publish cycle.
- **FAIL** — any non-zero exit.
### Step 7 — Stop on First Failure
When any step fails, print:
1. The failing step number and command.
2. The verbatim stderr output (or the relevant portion).
3. A one-line manual-recovery hint naming the exact `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.
## What This Skill Does NOT Do
- Does **not** name specific hook scripts — `publish.sh` discovers `setup/percolate-hooks/<target>/<hook-point>/*.sh` by convention and runs whatever's registered.
- Does **not** modify `publish.sh`, `publish-targets.sh`, or any source file — invocation orchestration only.
- Does **not** commit or push in the publish repo — the publish repo's git state is the PM's responsibility.
- Does **not** publish multiple targets in one invocation — single target per run.
- Does **not** edit `publish-targets.sh` to "fix" a missing target — exits with the target list instead.
- Does **not** run autonomously or skip the dry-run + PM-confirm gate, even when invoked from another skill or hook.
## Common Mistakes
- **Forgetting the target argument.** `/percolate` with no argument exits with the registered target list — same as an unknown target.
- **Expecting automatic git operations in the publish repo.** After a successful publish, run `git add / commit / push` in the publish repo manually or via a separate workflow.
- **Treating PASS-WITH-WARNINGS as a failure.** Phase 4 REVIEW lines are advisory — they flag personal-data patterns that warrant human review but do not block the publish.
No comments yet. Be the first to comment!