.md file to compare - side-by-side diff against sync
sync
description: "Triggers on prompt mention of 'sync'."
What it does for you
Keeps every machine current with the latest version automatically.
What it produces
A recent result, so you can see the kind of work it returns.
loading…
How to get it
These run inside the Snappy workspace. Want this working in your business? I set skills like this up with you, in one focused week.
For developers how this skill is built, graded, and how it runs
at a glance- the short version
what's inside - the parts that make up a skill 3/4 present
A skill is just a few plain-text files. Only the main one is required. The rest are optional, added as the work needs them. This is what the skill is made of; how it runs is just below.
state/skills/sync/SKILL.md
present
state/lib/sync.ts
present
state/bin/sync/
not present
state/skills/sync/AGENTS.md
present
how it's graded - what counts as a good run 4 criteria · 4 deterministic
Each row is one thing a good run has to get right. deterministic means a quick check decides, pass or fail. judge means the AI reads the result and rates it. Grading each piece on its own (instead of one overall score) shows exactly where a run fell short, so the fix is obvious.
how it runs - the shared frame every skill uses 5/5 present
Every skill runs the same way. One part does the work, a separate part checks it, and a short loader hands the AI exactly what it needs for the job. Anything this skill doesn't use shows a one-line note saying why, on purpose, not by accident.
state/log/evals.ndjson - Dirty tree is the #1 failure mode. Sync REFUSES on porcelain non-empty and scores 0 — it does NOT auto-commit. The fix is always at the call site (Stop hook, cron, regen worker), never inside sync.
- NEVER "fix" repeated dirty-tree by loosening the gate, auto-stashing, or auto-committing from inside sync. That hides which caller produced the dirt.
- NEVER use --direction both from a caller that just wrote files. Commit explicitly, then --push. --both is only for idle cron ticks.
- ALWAYS set SYNC_CALLER_ID (e.g. stop-hook, mac-mini-cron, regen-worker:<skill>). An unset caller_id blocks the repeat-offender attribution and is itself a bug.
- ALWAYS implement caller-side cooldown: after non-zero exit, do not re-invoke for 15 min UNLESS porcelain signature changed. Stamp at state/log/.sync-last-fail (3-line plain text: epoch, porcelain shasum, caller_id).
- Score is binary: 1.0 (HEAD === remote_head AND every step clean) or 0.0. No 0.5 tier. A 0.5 in evals.ndjson for sync IS a scorer bug.
what it has learned - fixes written back in over time sample
When a run hits something this skill didn't handle, the fix gets written back into the skill so it doesn't happen again. FIXED means it was corrected on the spot. LOGGED means it's queued for a bigger rewrite. Either way, the skill gets a little better and never makes the same mistake twice.
- Loading feedback rows…
how the work flows- who makes it, who checks it
state/bin/sync.sh --pull|--push|--both
git rev-parse HEAD` vs `git ls-remote origin main` after run — must match
SKILL.md- the skill, written out in plain English
sync
The "keep every machine holding snappy-os in agreement" skill. Runs git pull --rebase then git push, records the before/after HEAD + line delta in state/log/evals.ndjson. Two machines running this on a daily cron converge without manual intervention.
When to run
- Start of every session (first-action policy):
sync --direction pull.
At session start the tree should already be clean; if it isn't, the dirt is leftover from a prior session and needs triage before sync.
- End of every session: commit session work FIRST, then `sync --direction
push`. Calling push on a dirty tree is a score-0 run.
- Daily cron on the Mac Mini:
sync --direction bothat 03:00 local. The
cron caller should run the pre-flight check itself - if dirt exists the cron exits non-zero and pages, it does not guess.
- After a skill page rewrite: commit the rewrite, THEN `sync --direction
push` from the Stop hook so the regen lands on the laptop within minutes.
Steps
0. Pre-flight (caller's responsibility)
Dirty tree is the #1 failure mode and every recent score-0 run traces back here. Sync refuses to run on a dirty tree and will score 0 - it does NOT auto-commit for you. The fix is always at the call site, never inside sync.
Before invoking sync, at the call site:
cd ~/projects/snappy-os
git status --porcelain
Decide per line of output:
- Session-owned edits (the skill page you just rewrote, a new lib, a bin
script, a gitignore bump): commit with a descriptive message, then call sync. Auto-commit policy lives in hooks / caller code, not in this skill.
- Untracked ephemeral files (
.DS_Store, editor swapfiles, build output):
add the pattern to .gitignore, commit, then call sync. Do not rm them - they will come back.
- Unknown dirt (stash from another agent, partial edit you don't
recognize): STOP. Do not call sync. Surface git status + git diff to the operator.
Drop-in caller wrapper. Every hook / cron / script that invokes sync should wrap it like this - no exceptions:
cd ~/projects/snappy-os
# Caller attribution is mandatory. An unset SYNC_CALLER_ID produces an
# unattributable eval row and blocks the repeat-offender rule from
# identifying the offender — refuse up-front rather than fall through to
# "unknown". Use a stable slug (e.g. "stop-hook", "mac-mini-cron",
# "regen-worker:sync") so dirty-tree clusters map to a single site.
: "${SYNC_CALLER_ID:?SYNC_CALLER_ID must be set before calling sync}"
# Cooldown enforcement. A previous failure stamps state/log/.sync-last-fail
# with epoch seconds + porcelain signature + caller_id (three lines, no JSON
# so no jq dependency). Refuse to re-invoke sync for 15 minutes UNLESS the
# current porcelain signature differs from the one recorded at failure time.
# The 15-minute floor matches the caller-loop window in the Eval section —
# a shorter window (the previously documented 5 minutes / 300s) still let
# three attempts land inside the 15-min loop rule and produced clusters like
# sync-1776369043…-1776370127 and sync-1776370753/768 (two refusals 15s apart).
# If you edit the number here, update the prose + invariant #6 in the same
# commit; drift between the example and the rule IS the bug that trips regen.
stamp="state/log/.sync-last-fail"
cur_sig="$(git status --porcelain | shasum | awk '{print $1}')"
if [ -f "$stamp" ]; then
last_ts=$(sed -n '1p' "$stamp" 2>/dev/null || echo 0)
last_sig=$(sed -n '2p' "$stamp" 2>/dev/null || true)
now=$(date +%s)
if [ "$((now - last_ts))" -lt 900 ] && [ "$cur_sig" = "$last_sig" ]; then
echo "sync cooldown active (caller=$SYNC_CALLER_ID): $((900 - (now - last_ts)))s remaining, porcelain unchanged" >&2
exit 3
fi
fi
dirt="$(git status --porcelain)"
if [ -n "$dirt" ]; then
# Triage first:
# session-owned → commit it with a descriptive message here
# ignorable → add pattern to .gitignore, commit the bump
# unknown → exit 2 and surface `git status` — DO NOT call sync
echo "pre-sync dirt (caller=$SYNC_CALLER_ID):" >&2; echo "$dirt" >&2
# Stamp the failure so the cooldown above short-circuits the next call
# until the porcelain actually changes. Three lines (epoch, porcelain sig,
# caller_id) keep the format trivially parseable without jq and carry
# enough attribution for `state/lint/audit.ts` to confirm the stamp
# covers the same caller that is retrying.
mkdir -p state/log
{ date +%s; echo "$cur_sig"; echo "$SYNC_CALLER_ID"; } > "$stamp"
exit 2
fi
state/bin/sync.sh --push
# Clear the cooldown stamp on a clean entry — a stale stamp would mask a
# subsequent real failure behind the 5-minute window.
rm -f "$stamp"
Never use --both on a tree that might be dirty. Every recent dirty-tree failure (sync-1776369032-92622, sync-1776370064-93791) had direction: "both". If your caller just finished producing output (a skill rewrite, a new bin script, a log rotation), it already knows whether a commit is pending - commit it and call --push, or call --pull only. --both is reserved for idle cron ticks where the caller has produced nothing itself; from any code path that just wrote files, prefer explicit --pull then --push with a commit in between.
Invoking sync with a non-empty porcelain guarantees a score-0 run and accomplishes nothing. If you cannot commit from the caller (read-only context, unknown dirt, unowned edits), exit non-zero and escalate instead of calling sync anyway.
Known call sites that MUST install the Step 0 wrapper. If you add a new caller, update this list in the same commit so dirty-tree triage has a complete map of offenders:
- The session Stop hook (end-of-session push).
- The Mac Mini daily cron (
sync --direction bothat 03:00). - The regen worker (after rewriting a skill page in
state/skills/). - Any agent that edits files under
state/skills/,state/lib/,
state/bin/, state/verbs/, state/docs/, or the root .gitignore - those paths are tracked and will produce porcelain dirt.
Every caller MUST identify itself via SYNC_CALLER_ID (e.g. SYNC_CALLER_ID=stop-hook state/bin/sync.sh --both). The script records the value on the eval payload as caller_id (see Step 4) so dirty-tree clusters can be traced to a specific wrapper-less invocation without grepping the chain log by hand. An unset or "unknown" caller_id on a score: 0 run is itself a finding - the audit lint flags missing attribution so the offender surfaces in the next regen cycle. Use a single, stable env-var name across the whole repo (SYNC_CALLER_ID) - do not introduce aliases, they silently break the attribution index.
Do not retry sync on the same dirt. A dirty-tree exit means the porcelain is non-empty. Re-invoking sync without first committing, gitignoring, or escalating will produce an identical score-0 run and inflates the repeat-offender counter artificially. The caller must either resolve the dirt between attempts or stop calling sync until a human triages.
Retry-burst signatures to recognize (all score 0 and trip the caller-loop rule in the Eval section):
- 2+ score-0 runs inside 60 seconds from the same
caller_idwith an
unchanged porcelain signature - caller is in a tight retry loop.
- 3+ score-0 runs inside 15 minutes from the same
caller_idwith
unchanged before_head + porcelain signature - wrapper is missing or broken at that site. Real example: the cluster sync-1776369043…-1776370127 spanned ~18 minutes of five identical dirty-tree refusals; every hit had the same porcelain, so the caller was re-invoking sync without doing any triage between attempts.
- Any score-0 retry where the porcelain output is byte-identical to
the previous refusal - nothing changed, so nothing will change.
Callers MUST enforce a cooldown between failed sync attempts. Minimum contract: after a non-zero exit, a caller may not re-invoke sync for at least 15 minutes unless git status --porcelain is strictly different from the last failure snapshot. The 15-minute floor is not arbitrary - it equals the upper end of the caller-loop window in the Eval section, so a compliant caller cannot accidentally trip the loop rule by waiting "long enough but not long enough." A shorter cool-off (the previously documented 5 minutes) still allowed three attempts to land inside the 15-min loop window and produced exactly the sync-1776369043…-1776370127 cluster the brief flagged.
Hooks and crons implement the cool-off by stamping a local file at state/log/.sync-last-fail (gitignored, per-machine - not .json; the format is plain-text so a bare shell can read it without jq) on every non-zero sync exit and short-circuiting if the stamp is fresher than 15 minutes AND its porcelain signature matches a freshly hashed git status --porcelain | shasum of the working tree right now. Match = nothing has changed = re-invocation is guaranteed to re-fail. Mismatch = caller resolved (or introduced new) dirt = sync may be re-tried. Three-line schema (stable; change this and you must update the example wrapper above in the same commit):
<epoch-seconds>
<porcelain-shasum-hex>
<SYNC_CALLER_ID>
If a richer attribution payload is ever needed (ISO timestamp, before_head, primary_issue), append additional lines on the end - do NOT switch to JSON without also rewriting the example wrapper, or the two code paths will drift and the cool-off will silently stop firing (the exact class of drift that produced the recent dirty-tree cluster).
Two layers, both required: caller-side cool-off (preventive, lives at the call site) plus sync-side caller-loop refusal (defensive, lives in the script - reads recent eval rows and exits with primary_issue: "caller-loop" without burning network when the burst threshold trips). Caller-side alone leaks when a fresh hook fires on a new shell that never sees the stamp; sync-side alone leaks when a caller bypasses sync entirely (e.g. ad-hoc git push). Belt AND suspenders.
1. Scope
cd ~/projects/snappy-os
BEFORE_HEAD="$(git rev-parse HEAD)"
REMOTE_HEAD="$(git ls-remote origin main | awk '{print $1}')"
LOCAL_AHEAD="$(git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)"
REMOTE_AHEAD="$(git rev-list --count HEAD..origin/main 2>/dev/null || echo 0)"
Scope reports before/after SHAs, ahead/behind counts, and any unstaged work that would block a clean rebase. No writes.
2. Gate
- Refuse to run if
git status --porcelainshows unstaged changes - a dirty
tree must be resolved before sync. state/log/ is gitignored (per-machine; syncs via the eval endpoint), so any porcelain output is a real change. On refusal, emit the full porcelain output on stderr (at least the first 20 lines) AND the classification bucket for each line - session-owned vs. unknown vs. ephemeral. A bare "dirty tree, exiting" is not enough; the operator needs to see what the dirt is without running git themselves. Triage order:
- If the dirt is work the current session produced (new skill page, lib
edit, bin script), commit it with a descriptive message, then re-run sync.
- If the dirt is unknown (stash left from a previous session, partial edit
from another agent), git status + git diff to identify it. Never git checkout --, git restore, or git stash drop to make it go away; surface it to the operator with the exact paths.
- Untracked files that are truly ephemeral (build artifacts, editor
swapfiles) belong in .gitignore - add them there, commit the gitignore bump, then re-run sync. Do not leave them untracked as a workaround.
- Refuse to push if
git diff HEAD origin/maincontains the string
ANTHROPIC_API_KEY=, SNAPPY_MASTER_KEY=, or any .env.cache content (belt-and-suspenders; the file is already gitignored, but the lint is cheap).
- The gate runs twice: once before Step 3 (blocks a dirty rebase) and once
after the rebase completes but before the push (blocks a half-resolved conflict carrying forward). A rebase that leaves porcelain non-empty means --continue/--abort was never run - refuse the push and exit non-zero.
3. Act
# Pull (if direction includes pull):
git fetch origin main
git pull --rebase origin main
# Re-gate: rebase can leave conflict markers or untracked resolutions.
# Porcelain MUST be empty before proceeding to push.
if [ -n "$(git status --porcelain)" ]; then
echo "post-rebase tree is dirty — aborting before push" >&2
git status --porcelain >&2
exit 1
fi
# Push (if direction includes push):
git push origin main
# Verify convergence: fetch again and compare HEAD with remote.
# If they differ, another machine pushed between our push and this check —
# score 0 with primary_issue: "divergence-after-sync", do NOT auto-retry
# (a retry loop here risks a push-rejected storm across two machines).
git fetch origin main
AFTER_HEAD="$(git rev-parse HEAD)"
REMOTE_HEAD="$(git rev-parse origin/main)"
4. Log + eval
// caller_id identifies WHO invoked sync (Stop hook, cron entry, regen worker,
// interactive agent, one-off script). Required in every run so the
// repeat-offender rule is traceable — a null / "unknown" caller_id means the
// next agent cannot tell which hook or cron produced the dirty tree and the
// fix can only happen inside sync itself, which is the wrong place. Resolve
// via env SYNC_CALLER_ID (script echoes the resolved value on stderr so the
// operator can confirm it at invocation time). The eval writer MUST emit the
// field on every row — a missing field (not merely a null value) is a
// payload-shape violation; see invariant #7 below.
const caller_id = process.env.SYNC_CALLER_ID || "unknown";
// remote_head MUST be a non-empty string on every eval row. If the fetch
// never executed (gate refused before Step 3, DNS failure, token rejected),
// write the literal sentinel "unfetched" — never "" or undefined. An empty
// string cannot be distinguished from a fetch that returned zero refs, and
// the audit pass cannot tell whether the caller's dirt or the network is at
// fault. See invariant #8.
const remote_head_value = remote_head && remote_head.length > 0 ? remote_head : "unfetched";
append("chain", { run_id, skill: "sync", action: "done",
before_head, after_head, remote_head: remote_head_value,
pulled, pushed, caller_id });
// Sync is binary: either the local HEAD matches origin/main after the run,
// or it doesn't. There is no partial credit — a half-synced machine is
// indistinguishable from an un-synced machine for the next agent that
// wakes up on it. Do NOT introduce a 0.5 "close enough" tier. Pull-no-op
// (exit 0, before_head === after_head, remote_head ahead) is the case most
// often misgraded to 0.5 by a naive "commits_pulled > 0" check — the scorer
// MUST compare after_head against remote_head_value, not rely on the
// `git pull` exit code or a pulled-count heuristic.
const raw_score =
gate_clean && rebase_clean && push_clean && after_head === remote_head_value ? 1.0 :
0.0;
// Defensive coercion: clamp to {0.0, 1.0} at write-time so a future
// fractional-tier edit cannot land a 0.5 in evals.ndjson undetected.
// Invariant #1 says a 0.5 is always a scorer bug — the clamp enforces it.
const score_value = raw_score === 1.0 ? 1.0 : 0.0;
// pull-no-op is computed explicitly so it outranks divergence-after-sync in
// the ladder below. Without this branch, a `direction: pull` run that
// silently no-op'd gets bucketed as "divergence" which misdirects the fix
// (re-sync does nothing if pull itself is refusing to advance HEAD).
const pull_no_op =
(direction === "pull" || direction === "both") &&
after_head === before_head &&
remote_head_value !== "unfetched" &&
after_head !== remote_head_value;
// porcelain_sig snapshots the working-tree state captured at gate time
// (same sha1 the wrapper's cooldown stamp uses). Required on every eval
// row — the caller-loop detector needs it to tell a tight retry burst
// (unchanged porcelain) apart from two independent dirty-tree hits that
// happen to share a caller_id. A missing field here blinds the detector
// and the burst re-surfaces as N × dirty-tree rows instead of one
// caller-loop — the exact misattribution shape the recent regen brief
// shows (15-second retry pair logged as two dirty-tree rows).
const porcelain_sig = sha1(gate_porcelain_output || "");
// caller-loop detection: read the trailing 20 eval rows for skill:"sync"
// and flag this run as part of a retry burst when the same caller_id has
// produced at least one score:0 row within the last 60 seconds OR at
// least two within the last 15 minutes, all with matching before_head AND
// porcelain_sig. Matching signatures = nothing changed between attempts
// = re-invocation is guaranteed to re-fail, so the loop itself is the
// primary issue. If the tail read fails (evals.ndjson locked, truncated,
// path missing), set caller_loop_detected = false and emit
// detector_status: "read-failed" on the payload — do NOT silently fall
// through to the underlying issue, because that lets the loop hide
// behind a dirty-tree spike and sends the regen queue chasing the wrong
// fix (see invariant #9). This variable MUST be defined in the script;
// leaving it undefined evaluates as falsy and every burst-member gets
// misrouted to dirty-tree.
const tail = readEvalTail("sync", 20);
const now_s = Math.floor(Date.now() / 1000);
const peers = tail.filter(r =>
r.caller_id === caller_id &&
r.score === 0 &&
r.before_head === before_head &&
r.porcelain_sig === porcelain_sig
);
const in_60s = peers.filter(r => now_s - r.ts <= 60).length;
const in_15m = peers.filter(r => now_s - r.ts <= 900).length;
const caller_loop_detected = in_60s >= 1 || in_15m >= 2;
score("sync", run_id, {
score: score_value,
commits_pulled,
commits_pushed,
before_head,
after_head,
remote_head: remote_head_value,
caller_id,
porcelain_sig,
direction,
primary_issue:
caller_loop_detected ? "caller-loop" :
!gate_clean ? "dirty-tree" :
!rebase_clean ? "rebase-conflict" :
!push_clean ? "push-rejected" :
pull_no_op ? "pull-no-op" :
after_head !== remote_head_value ? "divergence-after-sync" :
null,
});
Eval
Actor: git pull --rebase + git push (standard git binary). Auditor: the post-sync git rev-parse HEAD vs git ls-remote origin main check - if they match, the machine is in agreement with origin.
Score convention (binary - no 0.5 tier):
| Outcome | Score | primary_issue |
|---|---|---|
| Pull + push both clean, HEAD matches remote | 1.0 | null |
| Pull clean, nothing to push, or push clean + nothing to pull, HEAD matches remote | 1.0 | null |
direction: pull ran but after_head !== remote_head (pull silently no-op'd or rebase skipped) | 0.0 | pull-no-op |
| Dirty tree - refused at the gate (preventable at call site; see Step 0) | 0.0 | dirty-tree |
| Post-rebase tree dirty (conflict not resolved, untracked resolution) | 0.0 | rebase-conflict |
| Rebase conflict (pull blew up, manual resolution needed) | 0.0 | rebase-conflict |
| Push rejected (non-fast-forward, permission) | 0.0 | push-rejected |
git fetch failed (origin unreachable, DNS, token) | 0.0 | fetch-failed / origin-unreachable |
| HEAD !== remote after both ops succeeded (another machine raced us) | 0.0 | divergence-after-sync |
2+ refused runs inside 60 seconds from the same caller_id with identical before_head + porcelain signature, OR 3+ inside 15 min | 0.0 | caller-loop |
A run only scores 1.0 when after_head === remote_head AND every gate/act step completed without incident. Anything less is 0.0 - a half-synced machine is indistinguishable from an un-synced one from the next agent's point of view, so partial credit misrepresents the system state.
Auditor invariants (lint-enforceable):
scoreMUST be exactly0.0or1.0. Any other value (including0.5,
0.25, 0.75) is an auditor violation - the sync auditor has no partial tier and the eval appender MUST reject it. If you see a 0.5 in evals.ndjson for skill: "sync", the scorer is broken, not the spec. Such rows MUST be retroactively rewritten to 0.0 on the next audit pass (same treatment as the 1.0-with-mismatched-HEAD case in invariant #3) so regen-queue statistics reflect reality. A regen brief whose "failures (score ≤ 0.5)" count exceeds its "hard failures (score 0)" count is the canonical tell that the scorer is emitting forbidden partial values - fix the scorer, do NOT widen the gate here to legitimize the partial tier.
score < 1.0MUST have a non-nullprimary_issuedrawn from the table
above. A score: 0 with primary_issue: null is a scorer bug - the caller skipped the triage ladder and fell through the default branch.
score: 1.0MUST haveprimary_issue: nullANDafter_head === remote_head.
A 1.0 with a non-empty primary_issue is a scorer bug; a 1.0 with after_head !== remote_head is outright false reporting and the run should be retroactively rewritten to 0.0 on the next audit pass.
- For
direction: pull, a clean run requiresafter_head === remote_head.
If the pull ran, exited 0, but after_head still equals before_head while remote_head is ahead, the pull silently no-op'd (wrong branch, detached HEAD, pull.ff=only refusal) - score 0 with pull-no-op.
- Every eval row MUST carry a non-empty
caller_idstring on the eval
payload. A run with caller_id: null or caller_id: "unknown" is a caller-attribution gap - the audit surfaces it alongside the primary issue so the next regen cycle can install the Step 0 wrapper at the unidentified site. Without a caller label the repeat-offender rule below cannot identify the site to patch, so the dirt keeps recurring. A caller that cannot name itself (bare shell, ad-hoc operator invocation) should pass SYNC_CALLER_ID=operator:<short-reason> rather than leaving it empty. Sync itself must never rescore or retry; it just refuses and records.
- When the caller-loop rule trips (2+ identical refused runs inside
60 seconds, OR 3+ inside 15 min, from the same caller_id with identical before_head + porcelain signature), subsequent runs in that burst MUST be scored with primary_issue: "caller-loop" rather than the underlying issue (dirty-tree, rebase-conflict, etc.). The 60-second threshold exists because sub-minute retry bursts are always programmatic (hook retry, cron re-fire, operator mashing) and always harmful - they inflate the dirty-tree counter and mask the single wrapper-less caller behind five identical eval rows. The loop itself is the problem; conflating it with the underlying issue sends the regen queue chasing the wrong fix and buries the true root cause (a caller that retries without resolving the dirt) behind a pile of dirty-tree hits.
- Concurrency: two distinct callers (different
caller_idvalues)
producing identical before_head + porcelain signatures within 60 seconds is NOT a caller-loop - it's two machines racing on the same uncommitted change. Score both with their underlying issue (dirty-tree) and flag contention: true on the payload so the audit can tell loops from races apart.
remote_headMUST be a non-empty string on every eval row. If the
fetch never completed (gate refused before Step 3, DNS failure, token rejected, offline), write the literal sentinel "unfetched" - never "", null, or undefined. The audit pass cannot distinguish "fetch returned zero refs" from "fetch never ran" without the sentinel, and every divergence-after-sync vs fetch-failed misclassification downstream flows from that ambiguity. Paired with the remote_head_value coercion in Step 4 - if you change the sentinel string, update both the code and this invariant in the same commit, or the lint and the scorer will drift.
- The caller-loop detector MUST be implemented in the script, not
merely spec'd. An undefined caller_loop_detected (referenced in the primary_issue ladder but never computed) silently evaluates to undefined → falsy → every burst-member routes to its underlying issue (dirty-tree) instead of caller-loop. That misattribution is exactly what surfaces in briefs as "N dirty-tree failures" for a cluster of runs that were really one loop. Every eval row MUST therefore carry porcelain_sig (sha1 of gate-time git status --porcelain); a row that omits the field is a payload-shape violation and blinds the detector on the next invocation. If the eval tail read fails (file locked, truncated, missing), the detector MUST emit detector_status: "read-failed" rather than silently returning false - otherwise a permanent read failure looks identical to "no loop" forever.
Repeat-offender rule. Three or more dirty-tree scores in the trailing 7 days is a caller bug, not a sync bug - the fix is to install the Step 0 wrapper at the offending call site (Stop hook, cron entry, regen worker). Sync's refusal is working as designed; the dirt is being produced upstream. Do not loosen the gate.
Diagnosing the offending caller when the rule trips. When the regen queue flags sync for repeated dirty-tree scores, do NOT rewrite this skill page to be more forgiving - find the caller that invokes sync on a dirty tree and wrap it. Concrete diagnostic path:
# 1. Pull the most recent dirty-tree runs and their timestamps.
grep '"skill":"sync"' state/log/evals.ndjson \
| grep '"primary_issue":"dirty-tree"' \
| tail -20
# 2. For each run_id, cross-reference the chain log to see who called sync.
# The `caller_id` field records the invoking context (hook name, cron
# slug, regen worker, bare shell). Resolved from the SYNC_CALLER_ID
# env var at invocation time — a missing or "unknown" value means the
# offender cannot be pinpointed, which is itself the bug to fix first.
grep '<run_id>' state/log/chain.ndjson
# 3. Inspect that caller's code/config and confirm it runs the Step 0
# pre-flight. If it doesn't, add the wrapper. If it does, the wrapper
# itself is broken — check that `git status --porcelain` is being
# evaluated in the same cwd as the sync invocation.
# 4. Surface invariant-#1 violations: rows for skill:sync with a score
# that is neither 0.0 nor 1.0. These point at a broken scorer, not a
# broken caller, and must be fixed separately from the dirty-tree
# path above (see Eval invariant 1 — such rows are retroactively
# rewritten to 0.0 so the regen queue statistics stop drifting).
grep '"skill":"sync"' state/log/evals.ndjson \
| grep -Ev '"score":(0\.0|1\.0)([,}]|$)' \
| tail -20
# 5. Retroactive rewrite procedure (the "audit pass" named in invariant
# #1 + #3). The fix is a read-then-write on evals.ndjson, NOT a
# spec loosening. Contract: any sync row whose score is neither 0.0
# nor 1.0 is rewritten to 0.0 with primary_issue preserved if
# present, else set to "scorer-drift". Any sync row with score 1.0
# but after_head !== remote_head is rewritten to 0.0 with
# primary_issue "divergence-after-sync". The rewrite appends a
# companion row tagged action: "retro-audit" with the original
# payload so the correction is auditable, then rewrites the
# offending row in place. NEVER drop the original row — silent
# deletions make the regen brief statistics non-reproducible.
#
# Operator one-liner to list candidates before rewriting (dry run):
grep '"skill":"sync"' state/log/evals.ndjson \
| awk -F'"score":' 'NF>1 { split($2,a,/[,}]/); s=a[1]; if (s!="0.0" && s!="1.0") print }' \
| tail -20
#
# The rewrite itself MUST be performed by an external audit tool,
# never by sync. Actor ≠ auditor: sync's script writes rows; the
# audit pass corrects them. If no such tool exists yet, stop and
# file it — do not hand-edit evals.ndjson, a partial edit that
# shifts byte offsets silently corrupts the file for tail-based
# readers.
Common offending callers in this repo, in order of historical frequency: the Stop hook (runs after a skill rewrite without committing), the 03:00 cron (runs on whatever dirt the last session left behind), the regen worker itself (edits a skill page then calls sync before committing). If one of these shows up three times in a week, the wrapper is missing or broken at that specific site - fix it there, not here.
Post-pull typecheck check (state/lib/ drift)
Most typecheck-broken breakages land when a pull touches state/lib/ - the kernel-ported libs occasionally ship dup-import or dup-key drift that the breaker re-surfaces on the next cron tick. Callers that pull --scope state and touch any file under state/lib/ MUST run npm run typecheck immediately after the pull completes and roll back the pull on non-zero exit (e.g. git reset --hard @{u}@{1}), same tick. Rolling forward with a broken tree leaves the breaker staring at a row the fixer will just "already-fixed" close on the next tick without progress.
The existing dirty-tree gate catches in-progress caller dirt but does NOT catch broken imports that arrive already committed on the remote. This check covers that gap. Paired with the structural gate in state/skills/snappy-fix.md#post-fix-typecheck-gate.
Stale-pull-blocked recipe (manual)
pull opens a P1 stale-pull-blocked situation when a local blob matches git HEAD but the gateway body is a different blob. The recipe is always the same: drop the offending manifest entry, re-push with the per-batch gateway probe. The previous state/bin/sync/auto-heal.sh driver was deleted 2026-04-26 along with the frictions ledger it consumed; do this by hand when a pull surfaces drift:
npx tsx state/lint/sync-integrity.ts --gateway --manifestto confirm the
drifted surface(s).
- Edit
state/log/sync-manifest.jsonto drop those entries. node bin/cli.js push --scope state(the per-batch probe inside push
validates round-trip on the way back up).
If a re-push fails, the Worker → DO Spaces write path is broken - triage manually (Worker logs, DO Spaces creds). Don't re-run blindly.
Gotchas
- **
remote_head: ""on gate-refused runs is a scorer bug, not a design
choice.** Confirmed in evals: sync-1776369032-92622, sync-1776370064-93791, sync-1776370753-10702 all carry remote_head:"" because the scorer exited at the dirty-tree gate before running git ls-remote. The fix is to set remote_head = "unfetched" in the scorer's initialization block (before any gate check) and only overwrite it if fetch actually runs. An empty string cannot be distinguished from "fetch returned zero refs" by the audit lint and silently breaks invariant #8.
- **
score: 0.5withprimary_issue: nullis a scorer bug, not a valid
outcome.** Confirmed in evals: sync-1776369043-92650 (direction: pull, after_head === before_head, remote_head ahead) was written as 0.5 instead of 0.0 with primary_issue: "pull-no-op". The pull-no-op detection branch MUST fire even when git pull exits 0 - a 0 exit from pull does NOT mean the local HEAD advanced to match remote; it only means pull didn't error. Compare after_head against remote_head explicitly after pull to catch silent no-ops.
- Dirty tree is now the #1 failure mode.
state/log/is gitignored, so
dirt in the porcelain output is always real (skill edits, new libs, bin scripts, gitignore changes). The script refuses and exits 1 with the dirt printed on stderr. Do not "fix" this by discarding - commit the work (if it's yours) or escalate (if you don't recognize it).
- **Regen brief with
failures > hard_failuresis scorer drift, not
skill drift.** The regen queue flags sync when failures (score ≤ 0.5) exceeds hard failures (score 0) because the gap IS a fractional score leaking through (e.g. 4 failures / 3 hard failures → one row between 0 and 0.5 landed in evals.ndjson). That is an invariant #1 violation - the sync auditor has no partial tier. The fix is the audit-pass rewrite in the Eval section's diagnostic step 5, not a rewrite of this skill page to legitimize the partial value. If you are a regen worker looking at this gotcha because that brief just fired: stop, run the diagnostic grep in step 4, and escalate to the scorer owner. Loosening the gate or introducing a 0.5 tier here buries the real bug and the brief will re-fire next week on the same condition.
- **Do not "fix" a repeating dirty-tree by loosening the gate, auto-stashing,
or auto-committing from inside sync.** Sync refuses on dirt by design - moving the commit into sync hides which caller produced the dirt and lets unreviewed edits land on main. The fix always lives at the call site (Stop hook, cron, regen worker). If you're tempted to edit the gate in Step 2, stop and follow the diagnostic path in the Eval section instead.
- Regen worker caveat. If a regen worker rewrites a skill page (like
this one) and then calls sync, it must commit the rewrite first - the rewrite IS the dirt. A regen that edits state/skills/*.md without a trailing commit will trip the gate on the very next sync. The commit MUST bundle all regen artifacts together: the rewritten skill page, the archived brief under state/log/regen-queue/done/, and the removed *.ready marker. Splitting those across commits leaves the next sync invocation staring at porcelain dirt from the un-committed archive mv and produces a score-0 run even though the rewrite itself was clean. The regen worker also MUST set SYNC_CALLER_ID=regen-worker:<skill-name> so the attribution index can tell a regen loop apart from a Stop-hook loop.
- Watch for untracked files that should be gitignored (
.DS_Store,
editor swapfiles, npm-debug.log, build outputs). If you see one, add it to .gitignore, commit, and re-run - don't just rm it, it will come back.
git pull --rebasereorders local commits after remote commits. The
dirty-tree gate is what prevents a half-staged file from being silently carried across the rebase. Do not bypass the gate.
- On fresh machines that have never pushed:
git push -u origin mainon
first run. The script detects the missing upstream and adds -u.
- If origin is unreachable (offline, wrong DNS, token expired),
git fetch
fails hard. Eval records primary_issue: "fetch-failed" (or "origin-unreachable" for DNS-level failures) with score 0.
- If two machines push concurrently, the loser sees
push-rejected
(non-fast-forward). The fix is to re-run sync - the next pull --rebase will pick up the winner's commit, and the next push will land.
Graduation
Graduated. Sidecar: state/bin/sync.sh. The prose above is kept for agents that need to read "why" before re-running.
Rubric
criteria:
- name: no_dirty_tree_on_entry
kind: deterministic
check: "The `git status --porcelain` command must return an empty string before `sync` is invoked, indicating a clean working directory."
- name: sync_caller_id_set
kind: deterministic
check: "The environment variable `SYNC_CALLER_ID` must be set and non-empty when `sync` is invoked."
- name: cooldown_respected
kind: deterministic
check: "If a `.sync-last-fail` file exists and its timestamp and porcelain signature match the current state, `sync` must refuse to run until the 15-minute cooldown period has passed or the porcelain signature changes."
- name: evals_log_update
kind: deterministic
check: "The `state/log/evals.ndjson` file must contain a new entry after `sync` execution, including the before/after HEAD and line delta."AGENTS.md- what the AI loads when this skill comes up
sync - loader
Per-turn rules for the sync skill. Full reference: state/skills/sync/SKILL.md. Do not skip these.
Critical Rules
- Dirty tree is the #1 failure mode. Sync REFUSES on porcelain non-empty and scores 0 - it does NOT auto-commit. The fix is always at the call site (Stop hook, cron, regen worker), never inside sync.
- NEVER "fix" repeated
dirty-treeby loosening the gate, auto-stashing, or auto-committing from inside sync. That hides which caller produced the dirt. - NEVER use
--direction bothfrom a caller that just wrote files. Commit explicitly, then--push.--bothis only for idle cron ticks. - ALWAYS set
SYNC_CALLER_ID(e.g.stop-hook,mac-mini-cron,regen-worker:<skill>). An unset caller_id blocks the repeat-offender attribution and is itself a bug. - ALWAYS implement caller-side cooldown: after non-zero exit, do not re-invoke for 15 min UNLESS porcelain signature changed. Stamp at
state/log/.sync-last-fail(3-line plain text: epoch, porcelain shasum, caller_id). - Score is binary:
1.0(HEAD === remote_head AND every step clean) or0.0. No 0.5 tier. A 0.5 in evals.ndjson for sync IS a scorer bug.
Commands
| ui dashboard | state/skills/sync/resources/ui.openui | |invoke: state/bin/sync.sh --pull|--push|--both |verify: git rev-parse HEAD vs git ls-remote origin main after run - must match |wrapper: cd ~/projects/snappy-os && : "${SYNC_CALLER_ID:?...}" && check stamp + porcelain → commit/exit-2 → state/bin/sync.sh --push → rm stamp |diagnose: grep '"skill":"sync"' state/log/evals.ndjson | grep '"primary_issue":"dirty-tree"' | tail -20 then cross-ref caller_id in chain.ndjson |eval log: state/log/evals.ndjson (skill: "sync") - required fields: caller_id, porcelain_sig, before_head, after_head, remote_head (literal "unfetched" if no fetch)
OpenUI Resource
- Skill-owned OpenUI Lang resource:
state/skills/sync/resources/ui.openui. Read it before rendering or editing this skill's generated component surface. - Treat this resource as a first-class artifact of the skill, not a generic chat response. Improve it when the skill's user-facing output needs to become richer.
- System resources compose OpenUI primitives and inherit SnappyChat tokens. Use
ui_contract: brandedin SKILL.md only for deliberate platform or client visuals.
Known Pitfalls
pull-no-op(pull exit 0 butafter_head === before_headwhile remote ahead) outranksdivergence-after-sync. Misgrading this as 0.5 sends the regen queue chasing the wrong fix.caller-looptrips when samecaller_id+ samebefore_head+ sameporcelain_sigproduces 2+ score-0 within 60s OR 3+ within 15min. MUST be implemented in script - undefinedcaller_loop_detectedsilently routes burst todirty-tree.remote_head: ""is a payload-shape violation. Use literal"unfetched"if fetch never ran. Empty cannot be distinguished from "fetch returned zero refs".- Regen worker rewriting a skill must commit (rewrite + archived brief + removed
.readymarker, all bundled) BEFORE calling sync, withSYNC_CALLER_ID=regen-worker:<skill-name>. - Two distinct callers producing identical
before_head + porcelainwithin 60s ≠ caller-loop. Score both with their underlying issue + flagcontention: true. - Never
git checkout --/git restore/git stash dropto clean a tree. Surface paths to the operator.
primary_issue ladder
caller-loop → dirty-tree → rebase-conflict → push-rejected → pull-no-op → divergence-after-sync → fetch-failed / origin-unreachable → null (clean)
Auditor invariants (lint-enforceable)
scoreMUST be0.0or1.0- clamp at write-timescore < 1.0MUST have non-nullprimary_issuefrom ladder abovescore: 1.0MUST haveprimary_issue: nullANDafter_head === remote_headdirection: pullclean run requiresafter_head === remote_head- Every row carries non-empty
caller_id(useoperator:<reason>for ad-hoc) - Burst members route to
caller-loop, not underlying issue - Two-caller race →
contention: trueflag, NOT caller-loop remote_headis non-empty string or literal"unfetched"caller_loop_detectedMUST be implemented; tail-read failure →detector_status: "read-failed"
Self-Test
An agent reading this should correctly:
- [ ] Refuse to auto-commit from inside sync
- [ ] Set SYNC_CALLER_ID before invocation
- [ ] Use
--push(not--both) after writing files - [ ] Treat 0.5 in evals.ndjson for sync as a scorer bug, not a real outcome
- [ ] Use literal
"unfetched"forremote_headwhen fetch didn't run
Self-report
If this loader fell short, append a line:
echo "[$(date -u +%FT%TZ)] sync: <what was missing>" >> state/log/loader-feedback.log
<!-- kernel-ok: historical attribution for the PID-loader pattern. -->
Self-correcting loader (PID feedback)
This is the rule that makes loaders learn. Ported from snappy-kernel, where it demonstrably worked: the snappy-dom-cartographer skill mapped Skool admin UI once, every later ship-agent inherited the full map for free.
Before you finish, do two things:
1. Fix gaps (P - proportional). MANDATORY. If this loader didn't cover your case - if you had to read another file, run an undocumented command, work around a wrong selector, or discover a quirk - you MUST attempt an Edit to this AGENTS.md before you log.
- Default action: edit this file. Surgical. Add the missing line, table
row, or code example where it belongs. No restructuring.
- Only
LOGGEDis allowed when: the fix needs >10 lines, spans multiple
files, or requires a structural rewrite. In that case the state/regen/drain.sh queue picks it up asynchronously.
- "I didn't have time" / "it's minor" / "the next agent will figure it out"
are NOT valid reasons. Minor gaps compound - that's the whole point of this loop.
- Concrete example of a valid P-fix: discover
agent-browser screenshot
takes path positionally not as --output <path>. One line in a table. Edit it in. Do not just log it.
- The goal: the next agent never has to leave the loader.
2. Log the result.
echo "[$(date -u +%FT%TZ)] sync: <what was missing or fixed> [FIXED|LOGGED]" >> state/log/loader-feedback.log
FIXED= you patched this loader inline (P-fix).LOGGED= too large for inline; the PostToolUse enqueue + Stop-hook drain
will rewrite the loader from scratch on next session-end.
Do not skip this. Every agent run must leave the system better than it found it. The loader is the setpoint; you are the sensor; the gap is the error signal; closing the gap is the correction.
api.ts- the code it can call
#!/usr/bin/env npx tsx
/**
* snappy-sync/api.ts -- Cross-machine file sharing via DO Spaces.
*
* Problem: Robert is on MacBook Pro, agent is SSH'd into Mac Mini. Screenshots
* and local files can't be shared directly. Instead of scp or iCloud, we upload
* to robert-storage CDN and copy the public URL to the clipboard. Robert pastes
* the URL into chat; the agent reads it like any other image.
*
* One function: share(filePath). Uploads, returns URL, copies to pasteboard.
* CLI supports --latest-screenshot for grabbing the most recent Desktop shot.
*/
import { execSync } from "child_process";
import { existsSync, readdirSync, realpathSync, statSync } from "fs";
import { homedir } from "os";
import { join, basename, extname } from "path";
import { env } from "./env.ts";
const UPLOAD_SCRIPT = join(
homedir(),
".claude/skills/snappy-image/scripts/cdn-upload.sh"
);
const DEFAULT_CONTEXT = "shared";
export interface ShareResult {
url: string;
file: string;
bytes: number;
}
function captureRegion(): string {
const tmpPath = `/tmp/snappy-region-${Date.now()}.png`;
try {
execSync(`screencapture -i "${tmpPath}"`, { stdio: "inherit" });
} catch {
throw new Error("screencapture failed (user cancelled?)");
}
if (!existsSync(tmpPath)) throw new Error("Region capture cancelled");
return tmpPath;
}
function latestScreenshot(): string {
const desktop = join(homedir(), "Desktop");
if (!existsSync(desktop)) throw new Error(`No ~/Desktop`);
const shots = readdirSync(desktop)
.filter((f) => /^Screen(?:shot| Shot)/i.test(f))
.map((f) => ({ f, t: statSync(join(desktop, f)).mtimeMs }))
.sort((a, b) => b.t - a.t);
if (shots.length === 0) throw new Error(`No screenshots on ~/Desktop`);
return join(desktop, shots[0].f);
}
export function share(
filePath: string,
context: string = DEFAULT_CONTEXT
): ShareResult {
if (!existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
if (!existsSync(UPLOAD_SCRIPT))
throw new Error(`Missing cdn-upload.sh at ${UPLOAD_SCRIPT}`);
const base = basename(filePath);
const ext = extname(base).slice(1) || "bin";
const stamp = Date.now();
const slug = `${stamp}-${base.replace(extname(base), "").replace(/[^a-zA-Z0-9-]/g, "-")}`;
const url = execSync(
`"${UPLOAD_SCRIPT}" --file "${filePath}" --context "${context}" --slug "${slug}"`,
{ encoding: "utf-8" }
).trim();
try {
execSync(`printf "%s" "${url}" | pbcopy`);
} catch {
// pbcopy missing (non-mac); non-fatal
}
try {
execSync(
`osascript -e 'display notification "${url}" with title "snappy-sync" subtitle "URL copied"'`
);
} catch {
// osascript missing; non-fatal
}
return { url, file: filePath, bytes: statSync(filePath).size };
}
if ((() => { try { return import.meta.url === `file://${realpathSync(process.argv[1])}`; } catch { return false; } })()) {
const [, , cmd, ...args] = process.argv;
if (!cmd || cmd === "help") {
console.log(`
snappy-sync/api.ts -- Share a local file across machines via CDN.
Commands:
share <file> Upload file, copy public URL to clipboard
share --latest-screenshot Upload most recent ~/Desktop screenshot
share --region Interactive region capture (like ⌘⇧4) then upload
help This message
Example (from MacBook Pro):
npx tsx ~/.claude/skills/snappy-sync/api.ts share --latest-screenshot
# URL is now on your clipboard. Paste into Claude chat.
`);
process.exit(0);
}
if (cmd === "share") {
const target =
args[0] === "--latest-screenshot"
? latestScreenshot()
: args[0] === "--region"
? captureRegion()
: args[0];
if (!target) {
console.error("Missing file path. Use: share <file> or share --latest-screenshot");
process.exit(2);
}
const result = share(target);
console.log(result.url);
process.exit(0);
}
console.error(`Unknown command: ${cmd}`);
process.exit(1);
}
scripts- helper scripts it can run
prose-only skill - 8 inline code blocks live in SKILL.md above (no state/bin/ sidecar yet).
how we check it- the checks, plus the last 10 runs
| timestamp | verb | score | primary_issue | artifact |
|---|---|---|---|---|
| 2026-04-27 05:53Z | - | 1.00 | - | - |
| 2026-04-25 04:11Z | - | 1.00 | - | - |
| 2026-04-21 15:58Z | - | 1.00 | - | - |
| 2026-04-21 15:57Z | - | 1.00 | - | - |
| 2026-04-21 03:53Z | - | 1.00 | - | - |
| 2026-04-20 03:41Z | - | 1.00 | - | - |
| 2026-04-19 22:30Z | - | 0.00 | - | - |
| 2026-04-18 22:28Z | - | 1.00 | - | - |
| 2026-04-18 22:27Z | - | 0.00 | - | - |
| 2026-04-18 22:25Z | - | 1.00 | - | - |