#!/usr/bin/env bash # gstack-brain-sync — drain queue, commit allowlisted paths, push to remote. # # Usage: # gstack-brain-sync --once drain queue, commit, push (default) # gstack-brain-sync --status print sync health as JSON # gstack-brain-sync --skip-file

add

to ~/.gstack/.brain-skip.txt # gstack-brain-sync --drop-queue --yes clear queue without committing # gstack-brain-sync --discover-new scan allowlist dirs, enqueue changed files # # Invoked by the preamble at skill START and END boundaries. No persistent # daemon. Typical run <1s when queue empty; ~200-800ms with network push. # # Singleton enforcement: flock on ~/.gstack/.brain-sync.lock. Concurrent # invocations queue and serialize. # # Env: # GSTACK_HOME — override ~/.gstack (aligns with writers). set -uo pipefail GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" # Maildir-style spool: one FILE per record, --.json. # Writers (gstack-brain-enqueue, --discover-new) create records via tmp-file # + atomic rename; the drain deletes exactly the files it snapshotted. No # shared inode between writer and drainer → no append/rewrite race. QUEUE_DIR="$GSTACK_HOME/.brain-queue.d" # Legacy single-file queue: kept ONLY for migration. Pre-spool writers # appended lines here; migrate_legacy_queue converts them to spool files. QUEUE="$GSTACK_HOME/.brain-queue.jsonl" ALLOWLIST="$GSTACK_HOME/.brain-allowlist" PRIVACY_MAP="$GSTACK_HOME/.brain-privacy-map.json" SKIP_FILE="$GSTACK_HOME/.brain-skip.txt" STATUS_FILE="$GSTACK_HOME/.brain-sync-status.json" LAST_PUSH_FILE="$GSTACK_HOME/.brain-last-push" LOCK_FILE="$GSTACK_HOME/.brain-sync.lock" DISCOVER_CURSOR="$GSTACK_HOME/.brain-discover-cursor" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG_BIN="$SCRIPT_DIR/gstack-config" # Egress receipt helpers (_receipted_git): receipt-before-send, fail-closed. . "$SCRIPT_DIR/gstack-egress-lib.sh" # origin host for receipt records (github.com etc). remote_host() { local url host url=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null || echo "") host="${url#*://}"; host="${host#*@}"; host="${host%%[/:]*}" echo "${host:-unknown}" } # Remote-specific hint for auth errors (branch on origin URL). remote_auth_hint() { local url url=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null || echo "") case "$url" in *github.com*|*@github.*) echo "run: gh auth status (and gh auth refresh if needed)" ;; *gitlab*) echo "run: glab auth status" ;; *) echo "check 'git remote -v' and your credentials" ;; esac } write_status() { # args: status_code message [extra_json_blob] local code="$1" local msg="$2" local extra="${3:-{\}}" local ts ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "") python3 - "$STATUS_FILE" "$code" "$msg" "$ts" "$extra" <<'PYEOF' 2>/dev/null || true import json, sys path, code, msg, ts, extra = sys.argv[1:6] try: extra_obj = json.loads(extra) if extra else {} except Exception: extra_obj = {} data = {"status": code, "message": msg, "ts": ts, **extra_obj} with open(path, "w") as f: json.dump(data, f) f.write("\n") PYEOF } # Read config; return 0 if sync active, 1 otherwise. sync_active() { if [ ! -d "$GSTACK_HOME/.git" ]; then return 1 fi local mode mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) [ "$mode" = "off" ] && return 1 return 0 } # Secret regex families — stdin scan. Exits 0 clean, 1 if hit. # Echoes the matching pattern family name on hit. Uses python3 -c (not # heredoc) so sys.stdin stays available for the diff content. secret_scan_stdin() { python3 -c " import sys, re patterns = [ ('aws-access-key', re.compile(r'AKIA[0-9A-Z]{16}')), ('github-token', re.compile(r'\\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})')), ('openai-key', re.compile(r'\\bsk-[A-Za-z0-9_-]{20,}')), ('pem-block', re.compile(r'-----BEGIN [A-Z ]{3,}-----')), ('jwt', re.compile(r'\\beyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\b')), ('bearer-token-json', # JSON-embedded auth headers. The optional Bearer/Basic/Token prefix # matters: real auth values include a literal space after the scheme # name, but the value charset below does not include spaces, so # without the optional prefix every Bearer token in a JSON blob slips # past the scanner. re.compile(r'\"(authorization|api[_-]?key|apikey|token|secret|password)\"\\s*:\\s*\"(Bearer |Basic |Token )?[A-Za-z0-9_./+=-]{16,}\"', re.IGNORECASE)), ] text = sys.stdin.read() for name, rx in patterns: m = rx.search(text) if m: snippet = m.group(0) if len(snippet) > 30: snippet = snippet[:30] + '...' print(name + ':' + snippet) sys.exit(1) sys.exit(0) " } # True (0) if the spool holds at least one record file. spool_has_records() { local f for f in "$QUEUE_DIR"/*.json; do [ -e "$f" ] && return 0 done return 1 } # Convert one legacy queue file's lines into spool record files (tmp + # os.replace, one file per line). Reads the file TWICE before unlinking: a # pre-rename writer can still append through its already-open fd after our # rename, and those appends land in the renamed file — the second pass # NARROWS the tail-race window (transition-only: it applies to pre-spool # writers, and a writer that appends after the second read but before the # unlink can still lose that line; spool-native writers are immune). # Unparseable lines migrate as-is; finalize_queue quarantines + warns on them. convert_legacy_file() { local legacy="$1" python3 - "$legacy" "$QUEUE_DIR" <<'PYEOF' 2>/dev/null || true import os, sys, time legacy, spool = sys.argv[1:3] def read_lines(path): try: with open(path) as f: return [l.rstrip("\r\n") for l in f if l.strip()] except (FileNotFoundError, OSError): return [] seq = 0 def write_spool(line): global seq seq += 1 tmp = os.path.join(spool, f".tmp-{os.getpid()}-m{seq}") with open(tmp, "w") as f: f.write(line + "\n") os.replace(tmp, os.path.join(spool, f"{int(time.time())}-{os.getpid()}-m{seq}.json")) written = set() for _pass in (1, 2): # second read closes the pre-rename-fd tail race for line in read_lines(legacy): if line not in written: # identical duplicates collapse, as the old rewrite did write_spool(line) written.add(line) os.unlink(legacy) PYEOF } # Legacy migration (transition window only). If the single-file queue holds # records, atomically rename it aside and convert each line to a spool file. # A concurrent OLD writer that recreates a fresh legacy file after the rename # simply gets migrated on the NEXT drain — nothing is lost, only deferred one # boundary. Runs inside the run lock, before the drain reads the spool. migrate_legacy_queue() { local migrating="$QUEUE.migrating" # Crash leftover: a prior migration renamed but died before unlink. Some of # its lines may already exist as spool files — re-converting duplicates is # safe (at-least-once; the drain dedups paths per snapshot and downstream # content-hash dedup absorbs re-syncs). Losing the file would not be. If # the conversion itself fails, the file stays for the next run (never rm a # non-empty .migrating file outside convert_legacy_file's own unlink). if [ -f "$migrating" ]; then if [ -s "$migrating" ]; then mkdir -p "$QUEUE_DIR" 2>/dev/null || return 0 convert_legacy_file "$migrating" else rm -f "$migrating" 2>/dev/null || true fi fi # If the leftover STILL holds records, the conversion failed (e.g. python3 # unavailable). The mv below would overwrite it and destroy those records — # exactly the never-destroy invariant above. Defer this run's migration; # the next run retries both files. [ -s "$migrating" ] && return 0 if [ -s "$QUEUE" ]; then mkdir -p "$QUEUE_DIR" 2>/dev/null || return 0 mv -f "$QUEUE" "$migrating" 2>/dev/null || return 0 convert_legacy_file "$migrating" fi return 0 } # Compute matched allowlisted, privacy-filtered path set from the spool. # Output: newline-delimited relative paths that should be staged. # # #2549: every non-staged queue entry is CLASSIFIED, never silently discarded. # When $2 is given, a JSON classification lands there: # {"retained": [privacy/mode-held paths that stay queued], # "dropped": {"skipped": [...], "invalid": [...], "unmatched": [...], "missing": [...]}} # retained entries would sync if the user raises artifacts_sync_mode, so they # stay in the queue; dropped classes can never sync (explicit skip, escape # attempt, no allowlist glob, not on disk) and are removed WITH a counted # status — the old behavior truncated the whole queue and reported every one # of these, including privacy holds, as "no allowlisted changes". # # Spool snapshot ($3): the sorted list of spool record filenames read here is # written to the snapshot manifest, one filename per line. finalize_queue # deletes exactly the manifest's files and never touches records created # after this listing — a concurrent enqueue is a separate file by # construction, so it simply rides to the next drain. compute_paths_to_stage() { local mode="$1" local class_file="${2:-}" local snapshot_file="${3:-}" python3 - "$GSTACK_HOME" "$QUEUE_DIR" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" "$class_file" "$snapshot_file" <<'PYEOF' import sys, json, os, fnmatch, glob gstack_home, spool_dir, allowlist_path, privacy_path, skip_path, mode, class_file, snapshot_file = sys.argv[1:9] def load_lines(path): try: with open(path) as f: return [l.strip() for l in f if l.strip() and not l.lstrip().startswith("#")] except FileNotFoundError: return [] def load_privacy_map(path): # Returns (entries, corrupt). Non-dict entries are filtered out # defensively — the map may be PULLED from the artifacts remote, so a # malformed entry like ["bad"] is remotely triggerable and used to raise # mid-classification (after the snapshot manifest was written), which the # old finalize turned into a full queue wipe. Any malformed shape also # marks the map CORRUPT: privacy classification cannot be trusted, so the # caller holds every queued record instead of guessing (a corrupt privacy # map silently treated as empty would over-share behavioral data). try: with open(path) as f: data = json.load(f) except FileNotFoundError: return [], False except json.JSONDecodeError: return [], True if not isinstance(data, list): return [], True # Expected: [{"pattern": "glob", "class": "artifact" | "behavioral"}] entries = [e for e in data if isinstance(e, dict)] return entries, len(entries) != len(data) allowlist_globs = load_lines(allowlist_path) privacy_map, privacy_corrupt = load_privacy_map(privacy_path) # Normalize skip entries to the POSIX form queued paths use, so a backslash # entry in .brain-skip.txt still matches on Windows. The drain is the safety # boundary that actually stages files, so it must normalize identically to # discover_new — otherwise an explicitly-skipped file gets committed. skip_lines = {s.replace(os.sep, "/") for s in load_lines(skip_path)} # Snapshot the spool: sorted (= chronological, filenames are epoch-first) # list of record files at read time. Records that appear after this listing # belong to the NEXT drain. Files we cannot read stay OUT of the manifest so # finalize never deletes a record this drain didn't actually consume. try: snapshot = sorted(n for n in os.listdir(spool_dir) if n.endswith(".json")) except (FileNotFoundError, NotADirectoryError): snapshot = [] queue_paths = set() consumed = [] for name in snapshot: try: with open(os.path.join(spool_dir, name)) as f: line = f.readline().strip() except OSError: continue consumed.append(name) if not line: continue try: obj = json.loads(line) p = obj.get("file") if isinstance(p, str): queue_paths.add(p) except json.JSONDecodeError: continue # unparseable record: finalize keeps + warns if snapshot_file: with open(snapshot_file, "w") as f: for name in consumed: f.write(name + "\n") def path_matches_any(path, globs): for pattern in globs: if fnmatch.fnmatchcase(path, pattern): return True return False def privacy_class(path, mapping): for entry in mapping: pat = entry.get("pattern") if pat and fnmatch.fnmatchcase(path, pat): return entry.get("class", "artifact") # Default class when no pattern matches: artifact (safe default). return "artifact" # mode filter: 'off' → nothing; 'artifacts-only' → only artifact class; # 'full' → both classes. def mode_allows(cls, mode): if mode == "off": return False if mode == "artifacts-only": return cls == "artifact" return True # full final = [] classified = {"retained": [], "dropped": {"skipped": [], "invalid": [], "unmatched": [], "missing": []}} if privacy_corrupt: # Fail-safe: with an untrustworthy privacy map, stage NOTHING and drop # NOTHING — retain every queued record until the map is fixed. The next # drain re-classifies from scratch. print("BRAIN_SYNC: warning: privacy map at " + privacy_path + " is malformed — holding all queued records until it is fixed", file=sys.stderr) classified["retained"] = sorted(queue_paths) queue_paths = set() for p in sorted(queue_paths): if p in skip_lines: classified["dropped"]["skipped"].append(p) continue # Must be under GSTACK_HOME root. Reject absolute + reject ../ escape. if p.startswith("/") or ".." in p.split("/"): classified["dropped"]["invalid"].append(p) continue # Must match at least one allowlist glob. if not path_matches_any(p, allowlist_globs): classified["dropped"]["unmatched"].append(p) continue # Must survive privacy mode filter — held entries STAY QUEUED (retained): # they would sync under a higher artifacts_sync_mode, and reporting them # as "no allowlisted changes" was #2549's misattribution. cls = privacy_class(p, privacy_map) if not mode_allows(cls, mode): classified["retained"].append(p) continue # Must exist on disk — can't stage what isn't there. if not os.path.exists(os.path.join(gstack_home, p)): classified["dropped"]["missing"].append(p) continue final.append(p) if class_file: with open(class_file, "w") as f: json.dump(classified, f) for p in final: print(p) PYEOF } # Finalize the drain: delete exactly the spool record files this drain # consumed (per the snapshot manifest) AND positively classified. Deletion is # EXPLICIT-DELETE-ONLY: a record is unlinked only when its path appears in # (staged paths ∪ classified dropped). The old polarity ("delete unless # retained") turned a missing/unparseable classification into retained=∅ and # wiped every snapshotted record — remotely triggerable via a malformed # pulled privacy map that raised AFTER the manifest write. Now a # missing/unparseable class_file or paths_file deletes NOTHING (warn + # return), and a path the classification never mentions stays queued. # The predecessor (a shared-file queue rewrite) had a lockless-append race # between its live re-read and the os.replace; with one file per record that # race class is structurally gone — a concurrent enqueue is a separate file # the snapshot never listed, so finalize cannot touch it. Crash semantics are # at-least-once: a drain that dies before finalize leaves its spool files in # place and the next run re-drains them; downstream content-hash dedup # absorbs the duplicates. Unparseable records move to $QUEUE_DIR/quarantine/ # (never deleted) so they stop re-warning at every boundary. Dropped-path # detail goes to a 0600 sidecar so the status line can stay content-free # (counts only). finalize_queue() { local snapshot_file="$1" # spool filenames this drain consumed, one per line local class_file="$2" # classification JSON from compute_paths_to_stage local paths_file="$3" # staged paths (compute_paths_to_stage stdout), one per line # Fail-open by design (a failed finalize self-corrects next run: re-stage → # nothing-to-commit), but say so — a silent failure here would let the # subsequent "ok/idle" status claim a drain that did not happen. python3 - "$QUEUE_DIR" "$snapshot_file" "$class_file" "$paths_file" "$GSTACK_HOME/.brain-sync-drops.json" <<'PYEOF' || echo "BRAIN_SYNC: warning: queue finalize failed — entries retained; next run re-drains" >&2 import json, os, sys, time spool_dir, snapshot_file, class_file, paths_file, drops_file = sys.argv[1:6] def lines(path): try: with open(path) as f: return [l.rstrip("\r\n") for l in f if l.strip()] except FileNotFoundError: return [] # Explicit-delete-only inputs. Either input unreadable → delete NOTHING. try: with open(class_file) as f: classified = json.load(f) if not isinstance(classified, dict): raise ValueError("classification is not an object") except Exception: print("BRAIN_SYNC: warning: classification unreadable — no queue records deleted; next run re-drains", file=sys.stderr) sys.exit(0) try: with open(paths_file) as f: staged = {l.strip() for l in f if l.strip()} except Exception: print("BRAIN_SYNC: warning: staged-paths file unreadable — no queue records deleted; next run re-drains", file=sys.stderr) sys.exit(0) dropped = set() for group in (classified.get("dropped", {}) or {}).values(): dropped.update(group) deletable = staged | dropped unparseable = 0 for name in lines(snapshot_file): full = os.path.join(spool_dir, name) try: with open(full) as f: rec = f.readline().strip() except OSError: continue # unreadable now: leave it for the next drain p = None try: p = json.loads(rec).get("file") except Exception: pass if not isinstance(p, str): # Never destroy what we can't read — but don't leave it re-warning at # every boundary either: move it aside for inspection. unparseable += 1 try: qdir = os.path.join(spool_dir, "quarantine") os.makedirs(qdir, exist_ok=True) os.replace(full, os.path.join(qdir, name)) except OSError: pass # quarantine move failed — leave in place; next run retries continue if p not in deletable: continue # retained / unclassified: stays queued (explicit-delete-only) try: os.unlink(full) # staged or dropped: fully processed except FileNotFoundError: pass if unparseable: print(f"BRAIN_SYNC: {unparseable} unparseable spool record(s) moved to quarantine (inspect {os.path.join(spool_dir, 'quarantine')})", file=sys.stderr) if dropped: fd = os.open(drops_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w") as f: json.dump({"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "dropped": classified.get("dropped", {})}, f) PYEOF } # Human-readable classification counts for status messages. queue_summary() { local class_file="$1" python3 - "$class_file" <<'PYEOF' 2>/dev/null || echo "" import json, sys try: with open(sys.argv[1]) as f: c = json.load(f) except Exception: print(""); sys.exit(0) d = c.get("dropped", {}) or {} parts = [] r = len(c.get("retained", [])) if r: parts.append(f"{r} privacy-held retained") for k in ("skipped", "unmatched", "missing", "invalid"): n = len(d.get(k, [])) if n: parts.append(f"{n} {k} dropped") print("; ".join(parts)) PYEOF } subcmd_once() { if ! sync_active; then # Silent no-op when feature not initialized / disabled. exit 0 fi # Singleton lock via atomic mkdir. `flock(1)` isn't on macOS by default; # `mkdir` is atomic on every POSIX filesystem. If another --once is already # running, skip (don't wait) — the next skill boundary will catch up. local lock_dir="${LOCK_FILE}.d" if ! mkdir "$lock_dir" 2>/dev/null; then # Is the lock stale? Check the pidfile inside. If process is dead, clear it. if [ -f "$lock_dir/pid" ]; then local lock_pid lock_pid=$(cat "$lock_dir/pid" 2>/dev/null || echo "") if [ -n "$lock_pid" ] && ! kill -0 "$lock_pid" 2>/dev/null; then # Stale lock — clear and retry once. rm -rf "$lock_dir" 2>/dev/null || true if ! mkdir "$lock_dir" 2>/dev/null; then exit 0 fi else # Lock is held by a live process. exit 0 fi else # Lock dir without pidfile — treat as held; don't touch. exit 0 fi fi echo "$$" > "$lock_dir/pid" 2>/dev/null || true # Release the lock on EVERY exit from here on — including the empty-queue # fast path and an INT during the detector's network push. Leaking it would # rely on next-run stale-pid detection, which PID reuse can defeat (kill -0 # matching an unrelated live process wedges sync at every boundary). The # mktemp block below re-traps with tempfile cleanup added; both traps keep # the lock removal. trap 'rm -rf "$lock_dir" 2>/dev/null || true' EXIT INT TERM # Convert any legacy single-file queue lines into spool records before the # drain reads the spool (transition window for pre-spool writers). migrate_legacy_queue # Janitor: reap orphaned enqueue temp files. A writer killed between its # tmp write and the atomic rename leaves `.tmp-*` behind forever — it never # becomes a record and nothing else touches it. One hour is far beyond any # live writer's write→rename window, so a fresh tmp (an in-flight enqueue) # is never touched. Runs inside the run lock, so it can't race the drain. find "$QUEUE_DIR" -maxdepth 1 -type f -name '.tmp-*' -mmin +60 -delete 2>/dev/null || true local mode mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) # #2516: advance the brain worktree gbrain indexes to the artifacts repo's # HEAD once a day — previously it only moved when setup-gbrain / sync-gbrain # / brain-restore ran, so brains silently served stale code forever. Runs # inside THIS run lock (never concurrent with the ingest steps below) and # before they touch the worktree. Attempt-throttled: the stamp is written on # ATTEMPT, so a persistently-failing advance warns once per 24h, not at # every skill boundary. The advance itself refuses dirty or unmanaged # worktrees and never force-removes (see gstack-gbrain-source-wireup). if [ -e "${GSTACK_BRAIN_WORKTREE:-$HOME/.gstack-brain-worktree}" ]; then local adv_stamp adv_now adv_last adv_age adv_stamp="$GSTACK_HOME/.brain-worktree-last-advance" adv_now=$(date +%s) adv_last=$(cat "$adv_stamp" 2>/dev/null || echo 0) case "$adv_last" in ''|*[!0-9]*) adv_last=0 ;; esac adv_age=$(( adv_now - adv_last )) if [ "$adv_age" -ge 86400 ]; then echo "$adv_now" > "$adv_stamp" 2>/dev/null || true if ! "$SCRIPT_DIR/gstack-gbrain-source-wireup" --advance-only 1>&2; then echo "BRAIN_SYNC: warning: brain worktree advance failed — gbrain may be indexing stale code (run gstack-gbrain-source-wireup to repair)" >&2 fi fi fi # #2549 unpushed-commit detector: a prior drain may have COMMITTED but # failed to push (auth blip, offline). The data was never lost — it sits in # a local commit — but nothing re-pushed it until NEW changes arrived. # Retry the push up front, inside the lock. Receipted fail-closed like # every other push; a receipt REFUSAL skips the retry without blocking the # rest of the drain (local staging must not wedge on receipt problems). # Guards: origin/ may not exist yet (first sync, deleted remote). # # Throttled: the preamble runs --once at EVERY skill boundary, so an # unthrottled retry would pay a full network push attempt per boundary in # exactly the steady states this targets (offline, broken auth) — and a # captive-portal push can block 30-75s against the header's "<1s when # idle" promise. Attempts are recorded (success or fail) and retried at # most every 10 minutes; the push itself never prompts for credentials and # bounds stalled transfers via git's own low-speed limits (portable — stock # macOS ships no `timeout` binary). # # Author-scoped — EXCLUSIVELY: `git push origin HEAD` publishes every # unpushed commit, so the retry fires only when ALL unpushed commits are # gstack-brain-sync's own. One interleaved user commit disables the # auto-retry entirely (adversarial review: an existential check would # silently auto-publish a user's manual ~/.gstack commit the moment a bot # commit sat in front of it). User commits ride along when a REAL drain # pushes, as before — the detector never publishes work it didn't create. local det_branch det_unpushed det_total det_now det_last det_branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") # Detached HEAD reads as the literal "HEAD" — origin/HEAD usually resolves, # so without this exclusion the detector would retry a doomed push forever. [ "$det_branch" = "HEAD" ] && det_branch="" if [ -n "$det_branch" ] && git -C "$GSTACK_HOME" rev-parse --verify --quiet "origin/$det_branch" >/dev/null 2>&1; then det_unpushed=$(git -C "$GSTACK_HOME" rev-list --count --author="gstack-brain-sync" "origin/$det_branch..HEAD" 2>/dev/null || echo 0) det_total=$(git -C "$GSTACK_HOME" rev-list --count "origin/$det_branch..HEAD" 2>/dev/null || echo 0) case "$det_unpushed" in ''|*[!0-9]*) det_unpushed=0 ;; esac case "$det_total" in ''|*[!0-9]*) det_total=0 ;; esac det_now=$(date +%s) det_last=$(cat "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || echo 0) case "$det_last" in ''|*[!0-9]*) det_last=0 ;; esac if [ "$det_unpushed" -gt 0 ] && [ "$det_unpushed" -eq "$det_total" ] && [ $(( det_now - det_last )) -ge 600 ]; then echo "$det_now" > "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || true local det_host det_host=$(remote_host) if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$det_host" curated-memory-git-push "artifacts_sync_mode!=off" \ bash -c 'GIT_TERMINAL_PROMPT=0 git -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=30 -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE" fi fi fi # Empty-queue fast path: this is the steady state at every skill boundary. # Skipping compute/finalize here is safe — with zero spool records there is # nothing to classify, retain, or drop, and a record created after this # check simply waits for the next boundary. The legacy file is checked too: # an OLD writer may have recreated it after the migration above (it gets # migrated next run, but the depth is honest now) — and so is a leftover # .migrating file: if its conversion failed above (e.g. python3 missing), # records are still pending, so "idle" would be dishonest. (The detector # above already ran: its whole point is re-pushing stranded commits when # the queue is empty.) The lock-release trap installed at acquisition # covers this exit. if ! spool_has_records && [ ! -s "$QUEUE" ] && [ ! -s "$QUEUE.migrating" ]; then write_status "idle" "queue empty" exit 0 fi local paths_file class_file snapshot_file paths_file=$(mktemp /tmp/brain-sync-paths.XXXXXX) || { rm -rf "$lock_dir" 2>/dev/null; write_status "error" "mktemp failed"; exit 1; } class_file=$(mktemp /tmp/brain-sync-class.XXXXXX) || { rm -f "$paths_file"; rm -rf "$lock_dir" 2>/dev/null; write_status "error" "mktemp failed"; exit 1; } snapshot_file=$(mktemp /tmp/brain-sync-snapshot.XXXXXX) || { rm -f "$paths_file" "$class_file"; rm -rf "$lock_dir" 2>/dev/null; write_status "error" "mktemp failed"; exit 1; } # Single trap covers all: lock cleanup AND tempfile cleanup. trap 'rm -f "$paths_file" "$class_file" "$snapshot_file" 2>/dev/null; rm -rf "$lock_dir" 2>/dev/null || true' EXIT INT TERM # Fail-safe (G1): a classifier that dies mid-run (ENOSPC/OOM/SIGKILL, or a # shape the defensive filters don't cover) may have already written the # snapshot manifest but no classification. Finalizing on that state is what # used to wipe the queue — so on a nonzero exit, warn loudly, do NOT call # finalize_queue, and leave everything queued for the next drain. if ! compute_paths_to_stage "$mode" "$class_file" "$snapshot_file" > "$paths_file"; then echo "BRAIN_SYNC: warning: queue classification failed — no records consumed; next run re-drains" >&2 write_status "error" "classification failed; queue preserved (next run retries)" exit 0 fi if [ ! -s "$paths_file" ]; then # Nothing stageable. Finalize the snapshot (retained entries survive; # classified drops removed; records created after the snapshot untouched). finalize_queue "$snapshot_file" "$class_file" "$paths_file" local summary summary=$(queue_summary "$class_file") write_status "idle" "no stageable changes${summary:+ ($summary)}" exit 0 fi # Stage with git add -f (forces past .gitignore=*) explicit paths only. while IFS= read -r p; do p="${p%$'\r'}" # Windows: compute_paths_to_stage's python print() emits CRLF; # a trailing CR makes the pathspec match nothing (silent no-stage). [ -z "$p" ] && continue git -C "$GSTACK_HOME" add -f -- "$p" 2>/dev/null || true done < "$paths_file" # Secret-scan staged diff. local scan_out scan_out=$(git -C "$GSTACK_HOME" diff --cached 2>/dev/null | secret_scan_stdin || true) if [ -n "$scan_out" ]; then # Hit — unstage, preserve queue, write loud status. git -C "$GSTACK_HOME" reset HEAD -- . >/dev/null 2>&1 || true local hint hint="secret pattern detected ($scan_out). Remediation: review the staged file, then run: gstack-brain-sync --skip-file OR edit the content." write_status "blocked" "$hint" echo "BRAIN_SYNC: blocked: $scan_out" >&2 exit 0 fi # Egress receipt for the push, written BEFORE the commit consumes the # queue (amendment C7 ordering): a refused receipt exits HERE, before any # queue mutation or local commit, so the queue stays intact and the next # run retries the whole drain. Content-free: git owns the bytes # (sha256:null). Fail-closed. local push_host receipt_err push_host=$(remote_host) if ! receipt_err=$(GSTACK_HOME="$GSTACK_HOME" "$SCRIPT_DIR/gstack-egress-receipt" write \ --sink brain-sync --host "$push_host" --class curated-memory-git-push \ --no-payload --consent "artifacts_sync_mode!=off" 2>&1 >/dev/null); then write_status "push_failed" "EGRESS_RECEIPT_FAILED: receipt not writable; push refused (queue preserved)" _gstack_egress_refusal "brain-sync push" "$(printf '%s' "$receipt_err" | head -c 300)" exit 1 fi # Commit with template message. local n ts n=$(wc -l < "$paths_file" | tr -d ' ') ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) local msg="sync: $n file(s) | $ts" git -C "$GSTACK_HOME" -c user.email="gstack@localhost" -c user.name="gstack-brain-sync" \ commit -q -m "$msg" 2>/dev/null || { # Nothing to commit (e.g. all files already committed). The drained # records leave the spool; retained + post-snapshot records survive. finalize_queue "$snapshot_file" "$class_file" "$paths_file" write_status "idle" "queue drained but no new changes to commit" exit 0 } # Push. On reject, fetch + merge (merge driver handles JSONL) + retry once. local push_err push_err=$(git -C "$GSTACK_HOME" push origin HEAD 2>&1 >/dev/null) || { # Check if this is an auth error first — no point retrying. if echo "$push_err" | grep -qiE "auth|permission|403|401|forbidden"; then local hint hint=$(remote_auth_hint) write_status "push_failed" "push failed: auth error; commit retained locally, will retry next run. fix: $hint" echo "BRAIN_SYNC: push failed: auth. fix: $hint" >&2 # Drained records leave the spool — they live in the local commit, which # the run-start detector re-pushes next time (#2549). Retained + # post-snapshot records survive the finalize. finalize_queue "$snapshot_file" "$class_file" "$paths_file" exit 0 fi # Try a fetch-and-merge + retry. The fetch and the retry push are their # own attempted-egress ops, each receipted fail-closed (a refusal falls # through to the push_failed path below). if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-fetch "artifacts_sync_mode!=off" \ bash -c 'git -C "$1" fetch origin 2>/dev/null' _ "$GSTACK_HOME"; then local branch branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main) if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1; then if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \ bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then finalize_queue "$snapshot_file" "$class_file" "$paths_file" date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE" write_status "ok" "pushed $n file(s) after rebase" exit 0 fi fi fi # Commit exists locally; the run-start detector re-pushes it next time. write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1); commit retained locally, will retry next run" finalize_queue "$snapshot_file" "$class_file" "$paths_file" exit 0 } # Success: drained records leave the spool (retained + post-snapshot survive). finalize_queue "$snapshot_file" "$class_file" "$paths_file" date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE" write_status "ok" "pushed $n file(s)" exit 0 } subcmd_status() { if [ -f "$STATUS_FILE" ]; then cat "$STATUS_FILE" else echo '{"status":"unknown","message":"no status file yet"}' fi # Supplemental info (not in status file). Depth = spool record files plus # any not-yet-migrated legacy queue lines (transition window), including a # crash-leftover .migrating file — its records are still pending too. local queue_depth spool_depth legacy_depth spool_depth=$(ls "$QUEUE_DIR"/*.json 2>/dev/null | wc -l | tr -d ' ') legacy_depth=0 [ -f "$QUEUE" ] && legacy_depth=$(wc -l < "$QUEUE" | tr -d ' ') [ -f "$QUEUE.migrating" ] && legacy_depth=$(( legacy_depth + $(wc -l < "$QUEUE.migrating" | tr -d ' ') )) queue_depth=$(( spool_depth + legacy_depth )) local last_push="never" [ -f "$LAST_PUSH_FILE" ] && last_push=$(cat "$LAST_PUSH_FILE" 2>/dev/null || echo never) local mode mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) printf '{"queue_depth":%s,"last_push":"%s","mode":"%s"}\n' "$queue_depth" "$last_push" "$mode" } subcmd_skip_file() { local path="${1:-}" if [ -z "$path" ]; then echo "Usage: gstack-brain-sync --skip-file " >&2 exit 1 fi mkdir -p "$GSTACK_HOME" # Avoid duplicate entries. if [ -f "$SKIP_FILE" ] && grep -Fxq "$path" "$SKIP_FILE"; then echo "already in skip list: $path" exit 0 fi echo "$path" >> "$SKIP_FILE" echo "added to skip list: $path" echo "(future writers will not enqueue this path; existing queue entries ignored on next --once)" } subcmd_drop_queue() { local force="${1:-}" if [ "$force" != "--yes" ]; then echo "Refusing: --drop-queue discards pending syncs. Pass --yes to confirm." >&2 exit 1 fi # Remove spool record files, then truncate any legacy queue remnant — # including a crash-leftover .migrating file, whose records would otherwise # resurrect on the next drain via migrate_legacy_queue after the user # explicitly discarded the queue. local n=0 f for f in "$QUEUE_DIR"/*.json; do [ -e "$f" ] || continue rm -f "$f" 2>/dev/null && n=$(( n + 1 )) done if [ -f "$QUEUE" ]; then local legacy_n legacy_n=$(wc -l < "$QUEUE" | tr -d ' ') n=$(( n + legacy_n )) : > "$QUEUE" fi if [ -f "$QUEUE.migrating" ]; then local mig_n mig_n=$(wc -l < "$QUEUE.migrating" | tr -d ' ') n=$(( n + mig_n )) rm -f "$QUEUE.migrating" 2>/dev/null || true fi if [ "$n" -eq 0 ]; then echo "queue already empty" exit 0 fi echo "dropped $n queue entries" } subcmd_discover_new() { if ! sync_active; then exit 0 fi # Walk allowlist globs; enqueue any file where mtime+size differs from cursor. python3 - "$GSTACK_HOME" "$ALLOWLIST" "$DISCOVER_CURSOR" <<'PYEOF' 2>/dev/null || true import sys, os, json, fnmatch, time from datetime import datetime, timezone gstack_home, allowlist_path, cursor_path = sys.argv[1:4] spool_dir = os.path.join(gstack_home, ".brain-queue.d") skip_path = os.path.join(gstack_home, ".brain-skip.txt") def load_lines(path): try: with open(path) as f: return [l.strip() for l in f if l.strip() and not l.lstrip().startswith("#")] except FileNotFoundError: return [] def load_cursor(path): try: with open(path) as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {} def save_cursor(path, data): try: with open(path, "w") as f: json.dump(data, f) except OSError: pass allowlist = load_lines(allowlist_path) # Normalize skip entries to the same POSIX form as `rel` below, so a # backslash entry in .brain-skip.txt still matches a normalized path on Windows. skip = {s.replace(os.sep, "/") for s in load_lines(skip_path)} cursor = load_cursor(cursor_path) new_cursor = dict(cursor) to_enqueue = [] # Walk all files under gstack_home, match against allowlist. for root, dirs, files in os.walk(gstack_home): # Skip .git and .brain-* state files. if ".git" in root.split(os.sep): continue for name in files: full = os.path.join(root, name) # Repo paths are POSIX-relative. os.path.relpath yields backslash # separators on Windows, which never match the forward-slash allowlist # globs (e.g. "projects/*/learnings.jsonl"), so discovery silently # enqueued nothing under projects/ on Windows. Normalize to "/". rel = os.path.relpath(full, gstack_home).replace(os.sep, "/") if rel.startswith(".brain-"): continue if not any(fnmatch.fnmatchcase(rel, pat) for pat in allowlist): continue if rel in skip: continue try: st = os.stat(full) key = f"{int(st.st_mtime)}:{st.st_size}" except OSError: continue if cursor.get(rel) != key: to_enqueue.append((rel, key)) # Write spool records directly. The previous implementation shelled out to # gstack-brain-enqueue once per file, but Windows Python cannot exec a # bash-shebang script (the spawn fails with a fork error), so discovery # enqueued nothing on Windows even after the path-match fix above. # Writing the record here is platform-agnostic; the drain step # (compute_paths_to_stage) still re-applies the skip-list + privacy filters. if to_enqueue: ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") written = [] try: # One spool FILE per record (tmp write + atomic os.replace), matching # gstack-brain-enqueue's maildir contract: writers and the drain never # share an inode, so a parallel writer or drain can't race this. # Compact separators match the shim's JSON shape. os.makedirs(spool_dir, exist_ok=True) for i, (rel, key) in enumerate(to_enqueue): rec = json.dumps({"file": rel, "ts": ts}, separators=(",", ":")) tmp = os.path.join(spool_dir, f".tmp-{os.getpid()}-d{i}") with open(tmp, "w") as f: f.write(rec + "\n") os.replace(tmp, os.path.join(spool_dir, f"{int(time.time())}-{os.getpid()}-d{i}.json")) written.append((rel, key)) except OSError: # Spool write failed (disk full, AV file lock). Leave the cursor # unadvanced for unwritten records so they are retried on the next # discover instead of being silently recorded as synced (which loses # the change until the file next changes). pass # Advance the cursor only for records actually written. for rel, key in written: new_cursor[rel] = key save_cursor(cursor_path, new_cursor) PYEOF } # -------- dispatch -------- case "${1:-}" in --once|"") subcmd_once ;; --status) subcmd_status ;; --skip-file) shift; subcmd_skip_file "${1:-}" ;; --drop-queue) shift; subcmd_drop_queue "${1:-}" ;; --discover-new) subcmd_discover_new ;; --help|-h) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//' ;; *) echo "Unknown subcommand: $1" >&2 echo "Run: gstack-brain-sync --help" >&2 exit 1 ;; esac