#!/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}"
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)
"
}
# Compute matched allowlisted, privacy-filtered path set from queue.
# 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".
compute_paths_to_stage() {
local mode="$1"
local class_file="${2:-}"
python3 - "$GSTACK_HOME" "$QUEUE" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" "$class_file" <<'PYEOF'
import sys, json, os, fnmatch, glob
gstack_home, queue, allowlist_path, privacy_path, skip_path, mode, class_file = sys.argv[1:8]
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):
try:
with open(path) as f:
data = json.load(f)
# Expected: [{"pattern": "glob", "class": "artifact" | "behavioral"}]
return data if isinstance(data, list) else []
except (FileNotFoundError, json.JSONDecodeError):
return []
allowlist_globs = load_lines(allowlist_path)
privacy_map = 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)}
# Read queue; collect unique file paths.
queue_paths = set()
try:
with open(queue) as f:
for line in f:
line = line.strip()
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
except FileNotFoundError:
pass
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": []}}
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
}
# #2549: surgical queue rewrite — replaces every whole-queue truncation
# (`: > "$QUEUE"`). Re-reads the LIVE queue at rewrite time (a writer may have
# enqueued while we were staging/pushing — those entries must survive; the old
# truncation destroyed them) and keeps every line whose file is either
# retained (privacy/mode-held) or not part of this drain at all. Atomic
# tmp+mv in the same directory. Dropped-path detail goes to a 0600 sidecar so
# the status line can stay content-free (counts only).
rewrite_queue() {
local paths_file="$1" # staged (drained) paths, one per line
local class_file="$2" # classification JSON from compute_paths_to_stage
# Fail-open by design (a failed rewrite 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" "$paths_file" "$class_file" "$GSTACK_HOME/.brain-sync-drops.json" <<'PYEOF' || echo "BRAIN_SYNC: warning: queue rewrite failed — entries retained; next run re-drains" >&2
import json, os, sys, time
queue, paths_file, class_file, drops_file = sys.argv[1:5]
def lines(path):
try:
with open(path) as f:
return [l.rstrip("\r\n") for l in f if l.strip()]
except FileNotFoundError:
return []
staged = set(lines(paths_file))
try:
with open(class_file) as f:
classified = json.load(f)
except Exception:
classified = {"retained": [], "dropped": {}}
retained = set(classified.get("retained", []))
dropped = set()
for group in (classified.get("dropped", {}) or {}).values():
dropped.update(group)
processed = staged | dropped
kept = []
seen_lines = set()
unparseable = 0
# LIVE re-read narrows (not fully closes) the concurrent-append window: the
# lockless enqueue can still land on the old inode between this read and the
# os.replace below. Vastly better than the old whole-queue truncation.
for line in lines(queue):
if line in seen_lines:
continue # identical duplicate lines collapse on rewrite
try:
p = json.loads(line).get("file")
except Exception:
unparseable += 1
kept.append(line) # unparseable line: keep, never destroy
seen_lines.add(line)
continue
if not isinstance(p, str) or p in retained or p not in processed:
kept.append(line)
seen_lines.add(line)
if unparseable:
import sys as _sys
print(f"BRAIN_SYNC: {unparseable} unparseable queue line(s) held (inspect {queue})", file=_sys.stderr)
tmp = queue + ".tmp." + str(os.getpid())
with open(tmp, "w") as f:
for l in kept:
f.write(l + "\n")
os.replace(tmp, queue)
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
local mode
mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off)
# #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/