#!/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

permanently exclude

(and any coupled # group it belongs to) from future syncs # gstack-brain-sync --unskip-file

undo --skip-file and queue

again # gstack-brain-sync --drop-queue --yes clear queue without committing # gstack-brain-sync --discover-new scan allowlist dirs, enqueue changed files # gstack-brain-sync --attention print skill start's attention lines # gstack-brain-sync --publish-removals [--yes] # list (or, with --yes, commit and push) # the removal of synced files gone from disk # # Invoked by the preamble at skill START and END boundaries. No persistent # daemon. Typical run <1s when queue empty; ~200-800ms with network push. # # One drain at a time: the drain lock (bin/gstack-brain-lock.sh) is shared # with every other gstack writer to ~/.gstack's git state. A drain that finds # it held skips; the next skill boundary catches up. # # Removals: with artifacts_sync_removals=on, each drain also publishes the # removal of synced files that are gone from disk (a deleted design doc, the # old directory after a project slug migration), so the remote and the brain # stop serving stale copies. A drain that would remove more than # REMOVAL_VALVE files at once publishes none of them and says so: that many # at once looks like a loss, not a cleanup. --publish-removals --yes publishes # them on demand, whatever the setting. # # A file the secret scan flags is held back on its own (with the rest of its # coupled group) and stays queued; everything else syncs. `--status` lists # each held file, the scanner rule that matched (never the matched text) and # both fixes. # # Sync status table. Skill start prints the attention line of every row that # applies; `ok` and `idle` print none. is this script's absolute path # and the held count. Rows are read by test/gstack-skill-start.test.ts. # # held trigger: status=held: the secret scan held back flagged files and synced the rest # line: ARTIFACTS_SYNC: attention: status=held held=. The secret scan is holding back file(s); everything else still syncs. See which files and how to fix them: --status # fix: edit the file so the scan no longer matches, or --skip-file (permanent; undo with --unskip-file ) # blocked trigger: status=blocked: a re-scan still flagged after the hold, so nothing was committed # line: ARTIFACTS_SYNC: attention: status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: --status # fix: edit or skip the files named by --status, or clear the batch with --drop-queue --yes # push_failed trigger: status=push_failed: a commit is saved locally but the push failed # line: ARTIFACTS_SYNC: attention: status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: --status # fix: the fix --status names (usually: gh auth status) # error trigger: status=error: the drain stopped before committing and kept the queue # line: ARTIFACTS_SYNC: attention: status=error. The last sync stopped before committing; the queue is kept and retried. See the cause: --status # fix: the cause --status names (git index lock, commit hook, git add failure) # unknown trigger: the status file holds a status code outside this table # line: ARTIFACTS_SYNC: attention: status=unknown. The sync status file is unreadable. See it: --status # fix: run --once to rewrite it # stale-push trigger: no push for 24 hours while the drain's drainable count is above zero # line: ARTIFACTS_SYNC: attention: stale-push. Files are ready to sync but nothing has been pushed for over 24 hours. See why: --status # fix: the waiting reason --status names (a coupled group waiting for a consistent generation, a git add failure) # stale-drain trigger: last_drain_at older than 24 hours while the queue directory holds records # line: ARTIFACTS_SYNC: attention: stale-drain. The sync has not run for over 24 hours while files wait in the queue. See why: --status # fix: if no gstack process is running, remove ~/.gstack/.brain-sync.lock.d, then run --once # # Env: # GSTACK_HOME — override ~/.gstack (aligns with writers). # Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a # pipe in the forked child before exec, with no reader on the other end. On # macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any # body >=512B blocks write() forever and the script hangs at startup with no # output. Compat level 50 restores the tempfile path. These scripts are # bash-3.2-clean, so the compat level costs them nothing. Not exported: the # guard is per-script, and it survives `bash script.sh` call sites that # bypass the shebang. BASH_COMPAT=50 set -uo pipefail . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } gstack_state_root_select GSTACK_HOME="$_gstack_sr_root" # 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" DISCOVER_CURSOR="$GSTACK_HOME/.brain-discover-cursor" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG_BIN="$SCRIPT_DIR/gstack-config" SELF_BIN="$SCRIPT_DIR/gstack-brain-sync" # Egress receipt helpers (_receipted_git): receipt-before-send, fail-closed. . "$SCRIPT_DIR/gstack-egress-lib.sh" # The drain lock shared with every gstack writer to the artifacts repo. . "$SCRIPT_DIR/gstack-brain-lock.sh" # Coupled groups: artifacts that reference each other and must reach the # remote as one generation. Members are globs sharing one "*" segment (the same # value binds every member); `check` names the consistency test the staged # generation must pass before it is committed. Paths not listed here are # independent. COUPLED_GROUPS='[{"name": "decisions", "members": ["projects/*/decisions.jsonl", "projects/*/decisions.active.json"], "check": "decisions-active"}]' REMOVAL_VALVE=20 # Fields every status write of this drain carries (see write_status). ST_HELD='[]' ST_DRAINABLE=0 ST_NOTE="" # 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 } # Status file schema (one JSON object; readers: --status, --attention, doctor): # status ok | idle | held | blocked | push_failed | error # held_count files the secret scan held back this drain (hits + dependents) # drainable queued files a drain could publish now: excludes scanner-held # and privacy-held records # last_drain_at UTC time this drain ran (every write happens inside a drain) # last_push_at UTC time of the last successful push, or null # ts same as last_drain_at (kept for older readers) # held [{path, rule, dependents[], fixes[]}]: one entry per flagged # path; rule is the scanner rule name, never the matched text # message human detail for --status; never printed at skill start # Only status, the counts and the timestamps feed skill start's attention # lines, each clamped to a closed set, digits, or a timestamp shape. write_status() { local code="$1" msg="$2${ST_NOTE}" ts last_push="" ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "") [ -f "$LAST_PUSH_FILE" ] && last_push=$(head -1 "$LAST_PUSH_FILE" 2>/dev/null || echo "") python3 - "$STATUS_FILE" "$code" "$msg" "$ts" "$last_push" "$ST_DRAINABLE" "$ST_HELD" <<'PYEOF' 2>/dev/null || true import json, re, sys path, code, msg, ts, last_push, drainable, held = sys.argv[1:8] try: held = [h for h in json.loads(held) if isinstance(h, dict)] except Exception: held = [] counted = set() for h in held: counted.add(h.get("path")) counted.update(h.get("dependents") or []) last_push = last_push.strip() data = { "status": code, "held_count": len(counted), "drainable": int(drainable) if drainable.isdigit() else 0, "last_drain_at": ts, "last_push_at": last_push if re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ", last_push) else None, "ts": ts, "held": held, "message": msg, } 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 scan over a staged diff file. Pattern families are the # pre-commit hook's; only the family name ever leaves this function, never the # matched text. The diff is read as bytes and decoded with replacement, so # undecodable bytes cannot crash the scan into a false "clean". # secret_scan gate exit 0 clean, 3 hit (prints the # family names); anything else is # a scanner failure (fail closed) # secret_scan attribute one pass: splits the diff on its # `diff --git` headers, pairs each # section with the NUL-separated # names from `git diff --name-only # -z` (same order), writes # "\t" per flagged # path; exit 0, or 4 when sections # and names disagree secret_scan() { python3 - "$@" <<'PYEOF' import re, sys 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)), ] def families(text): return [name for name, rx in patterns if rx.search(text)] def read(path): with open(path, "rb") as f: return f.read() mode = sys.argv[1] if mode == "gate": hits = families(read(sys.argv[2]).decode("utf-8", "replace")) if hits: print(",".join(hits)) sys.exit(3) sys.exit(0) diff_path, names_path, out_path = sys.argv[2:5] names = [n.decode("utf-8", "surrogateescape") for n in read(names_path).split(b"\0") if n] sections = [] for line in read(diff_path).split(b"\n"): if line.startswith(b"diff --git "): sections.append([]) elif sections: sections[-1].append(line) if len(sections) != len(names): sys.exit(4) with open(out_path, "w", encoding="utf-8", errors="surrogateescape", newline="\n") as out: for name, body in zip(names, sections): if "\n" in name or "\t" in name: sys.exit(4) hits = families(b"\n".join(body).decode("utf-8", "replace")) if hits: out.write(hits[0] + "\t" + name + "\n") PYEOF } # 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. # # Coupled groups ($COUPLED_GROUPS): a stageable path pulls in the rest of its # group (each member that exists, is allowlisted and passes the privacy mode), # so a generation is staged whole. A skipped member drops the whole group # (skip is per group, so the remote never gets a partial generation); a # privacy-held member retains the whole group. When $5 is given, the groups # with a staged member land there as JSON: # [{"name", "check", "members": [all member paths], "staged": [...]}] compute_paths_to_stage() { local mode="$1" local class_file="${2:-}" local snapshot_file="${3:-}" local transcripts_ok="${4:-0}" local groups_file="${5:-}" python3 - "$GSTACK_HOME" "$QUEUE_DIR" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" "$class_file" "$snapshot_file" "$transcripts_ok" "$groups_file" "$COUPLED_GROUPS" <<'PYEOF' import sys, json, os, fnmatch, glob, re gstack_home, spool_dir, allowlist_path, privacy_path, skip_path, mode, class_file, snapshot_file, transcripts_ok, groups_file, coupled = sys.argv[1:12] 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 def group_of(path): # (group spec, member paths) when path is a member of a coupled group. for spec in json.loads(coupled): for member in spec["members"]: m = re.fullmatch(re.escape(member).replace(r"\*", "([^/]+)"), path) if m: return spec, [x.replace("*", m.group(1), 1) for x in spec["members"]] return None, [] final = [] groups = {} 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): spec, members = group_of(p) if p in skip_lines or any(m in skip_lines for m in members): 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 # Staged transcript pages need transcript consent; without it they stay # queued (retained) and on disk, and push once the user consents. if transcripts_ok != "1" and p.split("/")[0] == "transcripts": classified["retained"].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 siblings = [m for m in members if m != p and path_matches_any(m, allowlist_globs) and os.path.exists(os.path.join(gstack_home, m))] if any(not mode_allows(privacy_class(m, privacy_map), mode) for m in siblings): classified["retained"].append(p) continue for m in [p] + siblings: if m not in final: final.append(m) if spec: groups.setdefault(members[0], {"name": spec["name"], "check": spec.get("check", ""), "members": members, "staged": []}) for g in groups.values(): g["staged"] = [m for m in g["members"] if m in final] if class_file: with open(class_file, "w") as f: json.dump(classified, f) if groups_file: with open(groups_file, "w") as f: json.dump(list(groups.values()), f) # `git cat-file --batch` input for every member's index entry: the # generation check reads exactly the bytes a commit would contain. with open(groups_file + ".batch", "w", newline="\n") as f: for g in groups.values(): for m in g["members"]: f.write(":" + m + "\n") for p in final: print(p) PYEOF } # Transcript consent comes from the one shared policy reader # (lib/transcript-consent.ts): every merged state root, the new@ cutoff and the # transcript_repos allowlist. Without bun nothing transcript-related can be # checked, so transcript pages are held (fail-closed). CONSENT_TS="$SCRIPT_DIR/../lib/transcript-consent.ts" transcript_policy() { command -v bun >/dev/null 2>&1 || return 2 GSTACK_STATE_ROOT="$GSTACK_HOME" bun run "$CONSENT_TS" "$@" } # 0 when staged transcript pages may sync at all. transcripts_consented() { transcript_policy --check 2>/dev/null } # Before every push: scan the unpublished range for transcript pages outside # the current consent; when some exist, the helper rewrites gstack-brain-sync's # own unpublished commits without them, or refuses (exit 3) when it cannot. # Returns 0 when the push may go ahead. A range that touches no transcript # page needs no scan (and no bun). guard_unpublished_range() { local branch range="HEAD" rc=0 branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") if [ -n "$branch" ] && [ "$branch" != "HEAD" ] && git -C "$GSTACK_HOME" rev-parse --verify --quiet "origin/$branch" >/dev/null 2>&1; then range="origin/$branch..HEAD" fi [ -n "$(git -C "$GSTACK_HOME" log --format= --name-only "$range" -- transcripts 2>/dev/null | head -1)" ] || return 0 transcript_policy --guard-unpublished || rc=$? if [ "$rc" -eq 2 ]; then echo "BRAIN_SYNC: push blocked: unpushed commits hold transcript pages and bun is not on PATH to check them against your transcript consent" >&2 fi [ "$rc" -eq 0 ] } # 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 # A record that stays queued (privacy-held, scanner-held, waiting group) only # needs one copy per path: every re-enqueue of such a path used to add a record # that was never consumed, so the queue count grew without meaning anything. kept = {} 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: kept.setdefault(p, []).append(name) continue # retained / unclassified: stays queued (explicit-delete-only) try: os.unlink(full) # staged or dropped: fully processed except FileNotFoundError: pass def age_key(name): head = name.split("-", 1)[0] return (int(head) if head.isdigit() else 0, name) for names in kept.values(): for older in sorted(names, key=age_key)[:-1]: try: os.unlink(os.path.join(spool_dir, older)) # the newest record carries the path except OSError: 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 } # Remove .git/index.lock when it is older than 10 minutes. Call only while # holding the drain lock: every gstack writer takes that lock before running # git here, so an old index lock is one a killed git process left behind. # Returns 1 when a younger index lock is present (a live git process). clear_stale_index_lock() { local idx="$GSTACK_HOME/.git/index.lock" [ -e "$idx" ] || return 0 [ -n "$(find "$idx" -maxdepth 0 -mmin +10 2>/dev/null)" ] || return 1 rm -f "$idx" 2>/dev/null || return 1 ST_NOTE="$ST_NOTE; removed a stale git index lock left by an interrupted git process" echo "BRAIN_SYNC: removed a stale git index lock (older than 10 minutes)" >&2 return 0 } # Stage the paths listed in $1 with `git add -f` (forces past .gitignore=*). # One batched add; when git refuses it, a per-path pass finds the paths it # refuses. Rewrites $1 to the staged paths; sets ADD_FAILED and FIRST_ADD_ERR. stage_paths() { local paths_file="$1" p err ADD_FAILED=0 FIRST_ADD_ERR="" # Windows: compute_paths_to_stage's python print() emits CRLF; a trailing # CR makes the pathspec match nothing. tr -d '\r' < "$paths_file" > "$paths_file.lf" if git --literal-pathspecs -C "$GSTACK_HOME" add -f --pathspec-from-file=- < "$paths_file.lf" >/dev/null 2>&1; then mv -f "$paths_file.lf" "$paths_file" return 0 fi : > "$paths_file" while IFS= read -r p; do p="${p%$'\r'}" [ -z "$p" ] && continue if err=$(git --literal-pathspecs -C "$GSTACK_HOME" add -f -- "$p" 2>&1 >/dev/null); then printf '%s\n' "$p" >> "$paths_file" else ADD_FAILED=$(( ADD_FAILED + 1 )) [ -z "$FIRST_ADD_ERR" ] && FIRST_ADD_ERR=$(printf '%s' "$err" | head -1) fi done < "$paths_file.lf" rm -f "$paths_file.lf" } # Plan the holds for one drain (one Python pass): # $1 scanner hits ("\t"), $2 groups JSON from # compute_paths_to_stage, $3 `git cat-file --batch` output for every group # member's index entry, $4 the staged paths. # A flagged path is held with the rest of its staged group (its dependents). # A group that is not held must stage whole and pass its generation check, or # all its staged members wait. Writes the held entries (JSON) to $5, the # paths to unstage to $6 and the waiting paths to $7. plan_holds() { python3 - "$@" "$SELF_BIN" <<'PYEOF' import json, sys hits_path, groups_path, blobs_path, staged_path, held_out, unstage_out, wait_out, self_bin = sys.argv[1:9] def lines(path): with open(path, encoding="utf-8", errors="surrogateescape") as f: return [l.rstrip("\r\n") for l in f if l.strip()] hits = [tuple(l.split("\t", 1)) for l in lines(hits_path) if "\t" in l] with open(groups_path) as f: groups = json.load(f) staged = set(lines(staged_path)) with open(blobs_path, "rb") as f: data = f.read() # Index blobs in batch order (None for an entry the index does not have). blobs, pos = {}, 0 for g in groups: for m in g["members"]: nl = data.index(b"\n", pos) header = data[pos:nl].split(b" ") pos = nl + 1 if header[-1] == b"missing": blobs[m] = None continue size = int(header[2]) blobs[m] = data[pos:pos + size] pos += size + 1 def decisions_active(members): log, snap = blobs.get(members[0]), blobs.get(members[1]) events = [] for line in (log or b"").decode("utf-8", "replace").split("\n"): try: e = json.loads(line) except ValueError: continue if isinstance(e, dict): events.append(e) retired = {e.get("supersedes") for e in events if e.get("kind") in ("supersede", "redact")} active = {e.get("id") for e in events if e.get("kind") == "decide" and e.get("id") not in retired} if snap is None: return not active try: rows = json.loads(snap.decode("utf-8", "replace")) except ValueError: return False return isinstance(rows, list) and all(isinstance(r, dict) for r in rows) \ and {r.get("id") for r in rows} == active CHECKS = {"decisions-active": decisions_active} hit_paths = {p for _, p in hits} held, unstage, waiting, held_groups = [], [], [], set() for rule, path in hits: group = next((g for g in groups if path in g["members"]), None) deps = [m for m in (group["staged"] if group else []) if m != path and m not in hit_paths and m in staged] if group: held_groups.add(group["members"][0]) also = " and the files coupled with it" if deps else "" held.append({ "path": path, "rule": rule, "dependents": deps, "fixes": [ f"edit {path} so the secret scan no longer matches ({rule}); the next sync re-scans it", f"{self_bin} --skip-file {path} (permanently excludes it{also} from future syncs; undo with {self_bin} --unskip-file {path})", ], }) for m in [path] + deps: if m not in unstage: unstage.append(m) for g in groups: if g["members"][0] in held_groups: continue members_staged = [m for m in g["staged"] if m in staged] if not members_staged: continue check = CHECKS.get(g.get("check")) if len(members_staged) == len(g["staged"]) and check and check(g["members"]): continue for m in members_staged: waiting.append(m) if m not in unstage: unstage.append(m) with open(held_out, "w") as f: json.dump(held, f) for path, rows in ((unstage_out, unstage), (wait_out, waiting)): with open(path, "w", encoding="utf-8", errors="surrogateescape", newline="\n") as f: f.writelines(r + "\n" for r in rows) PYEOF } # Fail closed: unstage everything, keep the queue, status blocked. block_drain() { git -C "$GSTACK_HOME" reset -q HEAD -- . >/dev/null 2>&1 || true ST_DRAINABLE=0 write_status "blocked" "$1; nothing was committed and the queue is kept. Edit or skip the flagged files ($SELF_BIN --skip-file ), or clear the batch with $SELF_BIN --drop-queue --yes" echo "BRAIN_SYNC: blocked: $1" >&2 } # Secret scan and group check for the staged paths in $1 (groups JSON in $2): # one scanner pass attributes findings to staged paths, plan_holds decides # what is held or waits, those paths are unstaged and leave $1 (their records # stay queued), then the whole remaining staged diff must re-scan clean. # Sets ST_HELD and WAITING. Returns 1 after falling back to block_drain. hold_flagged_paths() { local paths_file="$1" groups_file="$2" scan="$1.scan" rc=0 gate_rc=0 gate_out WAITING=0 : > "$scan.hits" : > "$scan.blobs" git -C "$GSTACK_HOME" diff --cached --no-renames > "$scan.diff" 2>/dev/null || rc=1 git -C "$GSTACK_HOME" diff --cached --no-renames -z --name-only > "$scan.names" 2>/dev/null || rc=1 [ "$rc" -eq 0 ] && { secret_scan attribute "$scan.diff" "$scan.names" "$scan.hits" || rc=1; } if [ "$rc" -eq 0 ] && [ -s "$groups_file.batch" ]; then tr -d '\r' < "$groups_file.batch" | git -C "$GSTACK_HOME" cat-file --batch > "$scan.blobs" 2>/dev/null || rc=1 fi [ "$rc" -eq 0 ] && { plan_holds "$scan.hits" "$groups_file" "$scan.blobs" "$paths_file" "$scan.held" "$scan.unstage" "$scan.wait" || rc=1; } if [ "$rc" -ne 0 ]; then block_drain "the secret scan could not tell which staged files it flagged" return 1 fi ST_HELD=$(cat "$scan.held") WAITING=$(grep -c . "$scan.wait" 2>/dev/null || true) WAITING=${WAITING:-0} if [ -s "$scan.unstage" ]; then if ! tr -d '\r' < "$scan.unstage" | git --literal-pathspecs -C "$GSTACK_HOME" reset -q HEAD --pathspec-from-file=- >/dev/null 2>&1; then block_drain "git could not unstage the held files" return 1 fi { grep -vxF -f "$scan.unstage" "$paths_file" || true; } > "$paths_file.kept" mv -f "$paths_file.kept" "$paths_file" fi if ! git -C "$GSTACK_HOME" diff --cached --no-renames > "$scan.diff" 2>/dev/null; then block_drain "git could not read the staged diff for the re-scan" return 1 fi gate_out=$(secret_scan gate "$scan.diff") || gate_rc=$? [ "$gate_rc" -eq 0 ] && return 0 if [ "$gate_rc" -eq 3 ]; then block_drain "a re-scan after holding back the flagged files still matched ($gate_out)" else block_drain "the secret scan failed (exit $gate_rc)" fi return 1 } # Write to $1 the tracked files gone from disk that the sync may remove, one # per line: allowlisted, not transcript pages (their consent guard owns those), # and a coupled group only when every member is gone. removal_candidates() { git -C "$GSTACK_HOME" ls-files -d -z > "$1.gone" 2>/dev/null || { rm -f "$1.gone"; return 1; } python3 - "$GSTACK_HOME" "$ALLOWLIST" "$COUPLED_GROUPS" "$1" <<'PYEOF' import fnmatch, json, os, re, sys home, allowlist_path, coupled, out = sys.argv[1:5] try: with open(allowlist_path) as f: globs = [l.strip() for l in f if l.strip() and not l.lstrip().startswith("#")] except FileNotFoundError: globs = [] with open(out + ".gone", "rb") as f: gone = {p for p in f.read().decode("utf-8", "surrogateescape").split("\0") if p} os.remove(out + ".gone") def removable(p): if p.split("/")[0] == "transcripts" or not any(fnmatch.fnmatchcase(p, g) for g in globs): return False for spec in json.loads(coupled): for member in spec["members"]: m = re.fullmatch(re.escape(member).replace(r"\*", "([^/]+)"), p) if m: return all(not os.path.exists(os.path.join(home, x.replace("*", m.group(1), 1))) for x in spec["members"]) return True with open(out, "w", newline="\n", encoding="utf-8", errors="surrogateescape") as f: for p in sorted(gone): if removable(p): f.write(p + "\n") PYEOF } # Commit the removal of the paths listed in $1 and push it, receipted like # every push. Returns 0 pushed, 1 commit failed, 2 committed but not pushed # (the run-start detector retries it). publish_removals() { local list="$1" n n=$(grep -c . "$list" 2>/dev/null || true) git --literal-pathspecs -C "$GSTACK_HOME" -c user.email="gstack@localhost" -c user.name="gstack-brain-sync" \ commit -q -m "sync: remove $n file(s) | $(date -u +%Y-%m-%dT%H:%M:%SZ)" --pathspec-from-file="$list" >/dev/null 2>&1 || return 1 guard_unpublished_range || return 2 GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$(remote_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" || return 2 date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE" } # The drain's removal step (artifacts_sync_removals=on). Records the outcome # in ST_NOTE; sets REMOVALS_PUSHED when the removals reached the remote. sync_removals() { local list n rc=0 REMOVALS_PUSHED=0 list=$(mktemp "${TMPDIR:-/tmp}/brain-sync-removals.XXXXXX") || return 0 if ! removal_candidates "$list"; then rm -f "$list" "$list.gone" ST_NOTE="$ST_NOTE; could not list removed files; none published" return 0 fi n=$(grep -c . "$list" 2>/dev/null || true) if [ "$n" -gt "$REMOVAL_VALVE" ]; then ST_NOTE="$ST_NOTE; $n synced file(s) are gone from disk, more than $REMOVAL_VALVE at once, so none of the removals were published (review: $SELF_BIN --publish-removals; publish: $SELF_BIN --publish-removals --yes)" echo "BRAIN_SYNC: holding $n removal(s), more than $REMOVAL_VALVE at once; review with $SELF_BIN --publish-removals" >&2 elif [ "$n" -gt 0 ]; then publish_removals "$list" || rc=$? case "$rc" in 0) REMOVALS_PUSHED=1; ST_NOTE="$ST_NOTE; published $n removal(s)" ;; 1) ST_NOTE="$ST_NOTE; committing $n removal(s) failed; the next sync retries" ;; *) ST_NOTE="$ST_NOTE; $n removal(s) committed locally but not pushed" ;; esac fi rm -f "$list" } subcmd_publish_removals() { local confirm="${1:-}" list n rc=0 sync_active || { echo "BRAIN_SYNC: artifacts sync is not active; nothing to publish" >&2; exit 1; } gstack_brain_lock_acquire "$GSTACK_HOME" try || { echo "BRAIN_SYNC: another sync is running; try again" >&2; exit 1; } list=$(mktemp "${TMPDIR:-/tmp}/brain-sync-removals.XXXXXX") || exit 1 trap 'rm -f "$list" "$list.gone"; gstack_brain_lock_release' EXIT INT TERM removal_candidates "$list" || { echo "BRAIN_SYNC: could not list removed files" >&2; exit 1; } n=$(grep -c . "$list" 2>/dev/null || true) if [ "$n" -eq 0 ]; then echo "no removals to publish" exit 0 fi if [ "$confirm" != "--yes" ]; then echo "$n synced file(s) are gone from disk:" sed 's/^/ /' "$list" echo "publish their removal: $SELF_BIN --publish-removals --yes" exit 0 fi publish_removals "$list" || rc=$? case "$rc" in 0) echo "published $n removal(s)" ;; 1) echo "BRAIN_SYNC: error: committing the removals failed" >&2; exit 1 ;; *) echo "BRAIN_SYNC: $n removal(s) committed locally; the push failed and retries at skill start" >&2; exit 1 ;; esac } subcmd_once() { if ! sync_active; then # Silent no-op when feature not initialized / disabled. exit 0 fi # One drain at a time, and never while another gstack writer runs git in # $GSTACK_HOME (bin/gstack-brain-lock.sh). A held lock means skip, not wait: # the next skill boundary catches up. Released on EVERY exit from here on, # including the empty-queue fast path and an INT during the detector's # network push; the mktemp block below re-traps with tempfile cleanup added. gstack_brain_lock_acquire "$GSTACK_HOME" try || exit 0 trap 'gstack_brain_lock_release' 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 # Removals go first, so a removal commit whose push fails is one more # unpushed commit for the detector below. REMOVALS_PUSHED=0 [ "$("$CONFIG_BIN" get artifacts_sync_removals 2>/dev/null || echo off)" = "on" ] && sync_removals # #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=0 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 ] && guard_unpublished_range; 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" det_total=0 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 # Stranded local commits that this run did not push keep the status at # push_failed: an "idle" here used to erase the failure one boundary later, # so a push that never recovered went silent. if [ "$det_total" -gt 0 ]; then write_status "push_failed" "$det_total local commit(s) are not pushed yet; the push retries at a skill start at most every 10 minutes (commits made outside gstack-brain-sync are never auto-pushed: push them with git -C $GSTACK_HOME push origin HEAD)" exit 0 fi if [ "$REMOVALS_PUSHED" = 1 ]; then write_status "ok" "queue empty"; else write_status "idle" "queue empty"; fi exit 0 fi local paths_file class_file snapshot_file groups_file paths_file=$(mktemp "${TMPDIR:-/tmp}/brain-sync-paths.XXXXXX") || { write_status "error" "mktemp failed"; exit 1; } class_file=$(mktemp "${TMPDIR:-/tmp}/brain-sync-class.XXXXXX") || { rm -f "$paths_file"; write_status "error" "mktemp failed"; exit 1; } snapshot_file=$(mktemp "${TMPDIR:-/tmp}/brain-sync-snapshot.XXXXXX") || { rm -f "$paths_file" "$class_file"; write_status "error" "mktemp failed"; exit 1; } groups_file=$(mktemp "${TMPDIR:-/tmp}/brain-sync-groups.XXXXXX") || { rm -f "$paths_file" "$class_file" "$snapshot_file"; write_status "error" "mktemp failed"; exit 1; } # Single trap covers all: lock release AND tempfile cleanup (the siblings # are the staging and hold steps' scratch files). trap 'rm -f "$paths_file" "$paths_file".* "$class_file" "$snapshot_file" "$groups_file" "$groups_file".* 2>/dev/null; gstack_brain_lock_release' 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. # Tightened consent (new@ cutoff, narrower transcript_repos): remove the # unpublished staged pages it now excludes before anything is staged. [ -d "$GSTACK_HOME/transcripts" ] && { transcript_policy --purge || true; } local transcripts_ok=0 transcripts_consented && transcripts_ok=1 if ! compute_paths_to_stage "$mode" "$class_file" "$snapshot_file" "$transcripts_ok" "$groups_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 local n_stageable n_stageable=$(grep -c . "$paths_file" 2>/dev/null || true) # Every add, reset and commit below needs the git index. The drain lock # proves no other gstack writer is running git here, so an index lock older # than 10 minutes is abandoned and is removed; a younger one is a live git # process (the user's own), which the drain reports and waits out. if ! clear_stale_index_lock; then ST_DRAINABLE=$n_stageable write_status "error" "a git index lock ($GSTACK_HOME/.git/index.lock) younger than 10 minutes shows another git process running in the artifacts repo; queue kept, the next sync retries" echo "BRAIN_SYNC: error: git index lock present; another git process is running in $GSTACK_HOME" >&2 exit 0 fi # Stage explicit paths only. A path git refuses to add leaves $paths_file, # so its record stays queued and the commit count stays honest. stage_paths "$paths_file" if [ ! -s "$paths_file" ]; then ST_DRAINABLE=$n_stageable write_status "error" "git add failed for all $ADD_FAILED path(s): ${FIRST_ADD_ERR:-unknown error}; queue preserved (next run retries)" echo "BRAIN_SYNC: error: git add failed for all $ADD_FAILED path(s): ${FIRST_ADD_ERR:-unknown error}" >&2 exit 0 fi if [ "$ADD_FAILED" -gt 0 ]; then ST_NOTE="$ST_NOTE; git add failed for $ADD_FAILED path(s), still queued (${FIRST_ADD_ERR:-unknown error})" echo "BRAIN_SYNC: warning: git add failed for $ADD_FAILED path(s): ${FIRST_ADD_ERR:-unknown error}" >&2 fi # Secret scan: flagged files (with their coupled groups) are held back and # stay queued; groups whose staged generation is inconsistent wait. Nothing # matching a scanner pattern is ever committed: the remaining staged diff # must re-scan clean, or the drain unstages everything (blocked). hold_flagged_paths "$paths_file" "$groups_file" || exit 0 ST_DRAINABLE=$(( ADD_FAILED + WAITING )) [ "$WAITING" -gt 0 ] && ST_NOTE="$ST_NOTE; $WAITING file(s) of a coupled group wait for a consistent generation (still queued)" local held_n ok_code="ok" held_n=$(printf '%s' "$ST_HELD" | grep -o '"rule"' | wc -l | tr -d ' ') if [ "$held_n" -gt 0 ]; then ok_code="held" ST_NOTE="$ST_NOTE; the secret scan held back $held_n flagged file(s) (and any coupled files), NOT synced; see held[] for each file's fixes" echo "BRAIN_SYNC: held $held_n flagged file(s); see $SELF_BIN --status" >&2 fi # A verified empty staged diff is the only "nothing to commit"; anything # else that stops the commit keeps the queue (status error). local cached_rc=0 git -C "$GSTACK_HOME" diff --cached --quiet >/dev/null 2>&1 || cached_rc=$? if [ "$cached_rc" -eq 0 ]; then finalize_queue "$snapshot_file" "$class_file" "$paths_file" if [ "$ok_code" = "held" ]; then write_status "held" "nothing else to sync" else write_status "idle" "queue drained but no new changes to commit" fi exit 0 fi if [ "$cached_rc" -ne 1 ]; then git -C "$GSTACK_HOME" reset -q >/dev/null 2>&1 || true ST_DRAINABLE=$n_stageable write_status "error" "git could not read the staged changes (exit $cached_rc); queue kept, the next sync retries" 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. A failed commit (a rejecting hook, a git # error) unstages, keeps the queue and says why. local n ts commit_err n=$(wc -l < "$paths_file" | tr -d ' ') ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) local msg="sync: $n file(s) | $ts" if ! commit_err=$(git -C "$GSTACK_HOME" -c user.email="gstack@localhost" -c user.name="gstack-brain-sync" \ commit -q -m "$msg" 2>&1 >/dev/null); then git -C "$GSTACK_HOME" reset -q >/dev/null 2>&1 || true ST_DRAINABLE=$(( ST_DRAINABLE + n )) write_status "error" "git commit failed: $(printf '%s' "$commit_err" | head -1 | head -c 200); queue kept, the next sync retries (check $GSTACK_HOME/.git/hooks)" echo "BRAIN_SYNC: error: git commit failed" >&2 exit 0 fi # Nothing outside the transcript consent is pushed; a refused rewrite keeps the commit local. guard_unpublished_range || { write_status "push_failed" "push blocked: unpushed commits hold transcript pages outside your transcript consent; commit retained locally"; finalize_queue "$snapshot_file" "$class_file" "$paths_file"; 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 && guard_unpublished_range; 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_code" "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_code" "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 # find, not `ls "$QUEUE_DIR"/*.json`: a deep spool overflows the argv limit # and the glob form then reported 0. spool_depth=$(find "$QUEUE_DIR" -maxdepth 1 -type f -name '*.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: $SELF_BIN --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 "(permanent: it no longer syncs, nor do the files of any coupled group it belongs to; queued records drop on the next --once; undo with $SELF_BIN --unskip-file $path)" } subcmd_unskip_file() { local path="${1:-}" if [ -z "$path" ]; then echo "Usage: $SELF_BIN --unskip-file " >&2 exit 1 fi if [ ! -f "$SKIP_FILE" ] || ! grep -Fxq "$path" "$SKIP_FILE"; then echo "not in skip list: $path" exit 0 fi local kept kept=$(mktemp "$SKIP_FILE.XXXXXX") || { echo "could not update $SKIP_FILE" >&2; exit 1; } { grep -Fxv "$path" "$SKIP_FILE" || true; } > "$kept" mv -f "$kept" "$SKIP_FILE" echo "removed from skip list: $path" if [ -f "$GSTACK_HOME/$path" ]; then "$SCRIPT_DIR/gstack-brain-enqueue" "$path" echo "(queued; the next sync publishes it, together with any coupled group it belongs to)" fi } # Skill start's attention lines: one per row of the sync status table in this # script's header that applies. Every value read from the status file is # clamped (status code to the table's closed set, counts to digits, # timestamps to their shape); the message, held paths and scanner output never # reach this output, only fixed text and this script's absolute path. subcmd_attention() { sync_active || exit 0 local line="" prefix code="" held="" drainable="" last_drain="" last_push="" bin="$SELF_BIN" if [ -f "$STATUS_FILE" ]; then line=$(head -c 65536 "$STATUS_FILE" 2>/dev/null | tr -d '\r\n') # The counts and timestamps precede every free-text field. prefix=${line%%, \"held\": *} code=$(printf '%s' "$prefix" | sed -n 's/^{"status": *"\([a-z_]*\)".*/\1/p') held=$(printf '%s' "$prefix" | sed -n 's/.*"held_count": *\([0-9][0-9]*\).*/\1/p') drainable=$(printf '%s' "$prefix" | sed -n 's/.*"drainable": *\([0-9][0-9]*\).*/\1/p') last_drain=$(printf '%s' "$prefix" | sed -n 's/.*"last_drain_at": *"\([^"]*\)".*/\1/p') last_push=$(printf '%s' "$prefix" | sed -n 's/.*"last_push_at": *"\([^"]*\)".*/\1/p') case "$code" in ok|idle|held|blocked|push_failed|error) ;; *) code="unknown" ;; esac fi case "$held" in ''|*[!0-9]*) held=0 ;; esac case "$drainable" in ''|*[!0-9]*) drainable=0 ;; esac local iso='[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]Z' case "$last_drain" in $iso) ;; *) last_drain="" ;; esac case "$last_push" in $iso) ;; *) last_push="" ;; esac case "$code" in held) echo "ARTIFACTS_SYNC: attention: status=held held=$held. The secret scan is holding back $held file(s); everything else still syncs. See which files and how to fix them: $bin --status" ;; blocked) echo "ARTIFACTS_SYNC: attention: status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: $bin --status" ;; push_failed) echo "ARTIFACTS_SYNC: attention: status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: $bin --status" ;; error) echo "ARTIFACTS_SYNC: attention: status=error. The last sync stopped before committing; the queue is kept and retried. See the cause: $bin --status" ;; unknown) echo "ARTIFACTS_SYNC: attention: status=unknown. The sync status file is unreadable. See it: $bin --status" ;; esac local now cutoff oldest="" oldest_epoch now=$(date +%s 2>/dev/null || echo "") case "$now" in ''|*[!0-9]*) return 0 ;; esac cutoff=$(date -u -d "@$(( now - 86400 ))" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -r "$(( now - 86400 ))" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "") [ -n "$cutoff" ] || return 0 oldest=$(find "$QUEUE_DIR" -maxdepth 1 -type f -name '*.json' 2>/dev/null | sed 's#.*/##' | sort | head -1) if [ "$drainable" -gt 0 ]; then oldest_epoch=${oldest%%-*} case "$oldest_epoch" in ''|*[!0-9]*) oldest_epoch=$now ;; esac if { [ -n "$last_push" ] && [[ "$last_push" < "$cutoff" ]]; } \ || { [ -z "$last_push" ] && [ "$oldest_epoch" -lt $(( now - 86400 )) ]; }; then echo "ARTIFACTS_SYNC: attention: stale-push. Files are ready to sync but nothing has been pushed for over 24 hours. See why: $bin --status" fi fi if [ -n "$oldest" ] || [ -s "$QUEUE" ] || [ -s "$QUEUE.migrating" ]; then if [ -z "$last_drain" ] || [[ "$last_drain" < "$cutoff" ]]; then echo "ARTIFACTS_SYNC: attention: stale-drain. The sync has not run for over 24 hours while files wait in the queue. See why: $bin --status" fi fi return 0 } 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:-}" ;; --unskip-file) shift; subcmd_unskip_file "${1:-}" ;; --drop-queue) shift; subcmd_drop_queue "${1:-}" ;; --discover-new) subcmd_discover_new ;; --attention) subcmd_attention ;; --publish-removals) shift; subcmd_publish_removals "${1:-}" ;; --help|-h) awk 'NR > 1 && /^# Heredoc delivery guard/ { exit } NR > 1' "$0" | sed 's/^# \{0,1\}//' ;; *) echo "Unknown subcommand: $1" >&2 echo "Run: gstack-brain-sync --help" >&2 exit 1 ;; esac