Merge oct7-brainsync: artifacts sync per-file hold (A4)

This commit is contained in:
garrytan committed 2026-10-07 20:14:42 +00:00
commit 3f930b4cb5
63 files changed
+1824 -522

No files matched your search

+8 -7
View File
@@ -63,14 +63,15 @@ If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -197,14 +197,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -67,14 +67,15 @@ If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -67,14 +67,15 @@ If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+12 -1
View File
@@ -260,6 +260,17 @@ EOF
exit 1
fi
# ---- drain lock ----
# Every gstack writer to the artifacts repo's git state takes the same lock,
# so a drain never meets this init's git index mid-write (and can treat an
# old index lock as abandoned).
. "$SCRIPT_DIR/gstack-brain-lock.sh"
if ! gstack_brain_lock_acquire "$GSTACK_HOME" wait; then
echo "gstack-artifacts-init: another gstack sync (pid ${_GSTACK_BRAIN_LOCK_HOLDER:-unknown}) is using $GSTACK_HOME; re-run when it finishes" >&2
exit 1
fi
trap 'gstack_brain_lock_release' EXIT
# ---- git init ----
if [ ! -d "$GSTACK_HOME/.git" ]; then
git -C "$GSTACK_HOME" init -q -b main 2>/dev/null || git -C "$GSTACK_HOME" init -q
@@ -282,7 +293,7 @@ EOF
ALLOWLIST="$GSTACK_HOME/.brain-allowlist"
ALLOWLIST_TMP=$(mktemp "$GSTACK_HOME/.brain-allowlist.XXXXXX")
ALLOWLIST_ASSEMBLED=$(mktemp "$GSTACK_HOME/.brain-allowlist.XXXXXX")
trap 'rm -f "$ALLOWLIST_TMP" "$ALLOWLIST_ASSEMBLED"' EXIT
trap 'rm -f "$ALLOWLIST_TMP" "$ALLOWLIST_ASSEMBLED"; gstack_brain_lock_release' EXIT
ALLOWLIST_MARKER='# ---- USER ADDITIONS BELOW ---- (survives re-init; above is managed)'
cat > "$ALLOWLIST_TMP" <<'EOF'
+89
View File
@@ -0,0 +1,89 @@
# gstack-brain-lock.sh — the one drain lock for every gstack writer to the
# artifacts repo's git state in $GSTACK_HOME.
#
# This file is NOT executable; source it:
#
# . "$(dirname "$0")/gstack-brain-lock.sh"
#
# Writers that take it: the drain (gstack-brain-sync --once), the skill-start
# merge (gstack-skill-start), gstack-artifacts-init, gstack-brain-restore and
# gstack-gbrain-source-wireup. Holding it is what lets the drain treat an old
# .git/index.lock as abandoned: no other gstack writer can be running git there.
#
# Provides:
# gstack_brain_lock_acquire <gstack_home> <try|wait>
# Returns 0 when this process holds the lock afterwards, 1 otherwise.
# try — give up at once when a live process holds it.
# wait — retry once a second for GSTACK_BRAIN_LOCK_WAIT seconds (default
# 120), then give up.
# A child of the holder (the drain runs the wireup's daily advance; restore
# runs the wireup) inherits GSTACK_BRAIN_LOCK_TOKEN; when it matches the
# lock's owner file the child proceeds without taking or releasing it.
# On failure, _GSTACK_BRAIN_LOCK_HOLDER names the holder's pid (or "").
# gstack_brain_lock_release
# Removes the lock only when this process took it and still owns it.
#
# The lock is a directory (mkdir is atomic on every POSIX filesystem; flock(1)
# is not on macOS) holding `pid` and `owner`. A lock whose pid is dead is
# stale and is cleared. A lock directory with no pid file is a holder killed
# between mkdir and the pid write once it is older than 10 minutes.
_GSTACK_BRAIN_LOCK_DIR=""
_GSTACK_BRAIN_LOCK_MINE=0
_GSTACK_BRAIN_LOCK_HOLDER=""
_gstack_brain_lock_try_once() {
local dir="$1" pid
if mkdir "$dir" 2>/dev/null; then
GSTACK_BRAIN_LOCK_TOKEN="$$-${RANDOM:-0}-$(date +%s 2>/dev/null || echo 0)"
export GSTACK_BRAIN_LOCK_TOKEN
printf '%s\n' "$$" > "$dir/pid" 2>/dev/null || true
printf '%s\n' "$GSTACK_BRAIN_LOCK_TOKEN" > "$dir/owner" 2>/dev/null || true
_GSTACK_BRAIN_LOCK_MINE=1
return 0
fi
if [ -f "$dir/pid" ]; then
pid=$(head -1 "$dir/pid" 2>/dev/null || echo "")
_GSTACK_BRAIN_LOCK_HOLDER="$pid"
case "$pid" in ''|*[!0-9]*) return 1 ;; esac
kill -0 "$pid" 2>/dev/null && return 1
elif [ -z "$(find "$dir" -maxdepth 0 -mmin +10 2>/dev/null)" ]; then
_GSTACK_BRAIN_LOCK_HOLDER=""
return 1
fi
rm -rf "$dir" 2>/dev/null || true
return 1
}
gstack_brain_lock_acquire() {
local home="$1" mode="${2:-try}" waited=0 limit
_GSTACK_BRAIN_LOCK_DIR="$home/.brain-sync.lock.d"
_GSTACK_BRAIN_LOCK_MINE=0
_GSTACK_BRAIN_LOCK_HOLDER=""
if [ -n "${GSTACK_BRAIN_LOCK_TOKEN:-}" ] && [ -f "$_GSTACK_BRAIN_LOCK_DIR/owner" ] \
&& [ "$(head -1 "$_GSTACK_BRAIN_LOCK_DIR/owner" 2>/dev/null)" = "$GSTACK_BRAIN_LOCK_TOKEN" ]; then
return 0
fi
mkdir -p "$home" 2>/dev/null || true
limit="${GSTACK_BRAIN_LOCK_WAIT:-120}"
case "$limit" in ''|*[!0-9]*) limit=120 ;; esac
while :; do
_gstack_brain_lock_try_once "$_GSTACK_BRAIN_LOCK_DIR" && return 0
# A stale lock was just cleared: take it now instead of after a sleep.
[ -d "$_GSTACK_BRAIN_LOCK_DIR" ] || { _gstack_brain_lock_try_once "$_GSTACK_BRAIN_LOCK_DIR" && return 0; }
[ "$mode" = "wait" ] || return 1
[ "$waited" -ge "$limit" ] && return 1
sleep 1
waited=$(( waited + 1 ))
done
}
gstack_brain_lock_release() {
[ "$_GSTACK_BRAIN_LOCK_MINE" = "1" ] || return 0
if [ "$(head -1 "$_GSTACK_BRAIN_LOCK_DIR/owner" 2>/dev/null)" = "${GSTACK_BRAIN_LOCK_TOKEN:-}" ]; then
rm -rf "$_GSTACK_BRAIN_LOCK_DIR" 2>/dev/null || true
fi
_GSTACK_BRAIN_LOCK_MINE=0
unset GSTACK_BRAIN_LOCK_TOKEN
return 0
}
+10 -1
View File
@@ -89,9 +89,18 @@ EOF
fi
fi
# ---- drain lock ----
# Every gstack writer to the artifacts repo's git state takes the same lock;
# the wireup this script runs at the end inherits it.
. "$SCRIPT_DIR/gstack-brain-lock.sh"
if ! gstack_brain_lock_acquire "$GSTACK_HOME" wait; then
echo "gstack-brain-restore: another gstack sync (pid ${_GSTACK_BRAIN_LOCK_HOLDER:-unknown}) is using $GSTACK_HOME; re-run when it finishes" >&2
exit 1
fi
# ---- clone to staging ----
STAGING=$(mktemp -d "${TMPDIR:-/tmp}/gstack-brain-restore.XXXXXX")
trap 'rm -rf "$STAGING" 2>/dev/null' EXIT
trap 'rm -rf "$STAGING" 2>/dev/null; gstack_brain_lock_release' EXIT
echo "Cloning $REMOTE_URL to staging..."
RESTORE_HOST="${REMOTE_URL#*://}"; RESTORE_HOST="${RESTORE_HOST#*@}"; RESTORE_HOST="${RESTORE_HOST%%[/:]*}"
+586 -115
View File
@@ -4,15 +4,50 @@
# Usage:
# gstack-brain-sync --once drain queue, commit, push (default)
# gstack-brain-sync --status print sync health as JSON
# gstack-brain-sync --skip-file <p> add <p> to ~/.gstack/.brain-skip.txt
# gstack-brain-sync --skip-file <p> permanently exclude <p> (and any coupled
# group it belongs to) from future syncs
# gstack-brain-sync --unskip-file <p> undo --skip-file and queue <p> 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
#
# 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.
# 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.
#
# 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. <bin> is this script's absolute path
# and <N> 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=<N>. The secret scan is holding back <N> file(s); everything else still syncs. See which files and how to fix them: <bin> --status
# fix: edit the file so the scan no longer matches, or <bin> --skip-file <path> (permanent; undo with <bin> --unskip-file <path>)
# 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: <bin> --status
# fix: edit or skip the files named by <bin> --status, or clear the batch with <bin> --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: <bin> --status
# fix: the fix <bin> --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: <bin> --status
# fix: the cause <bin> --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: <bin> --status
# fix: run <bin> --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: <bin> --status
# fix: the waiting reason <bin> --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: <bin> --status
# fix: if no gstack process is running, remove ~/.gstack/.brain-sync.lock.d, then run <bin> --once
#
# Env:
# GSTACK_HOME — override ~/.gstack (aligns with writers).
@@ -45,14 +80,28 @@ 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"
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"}]'
# 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() {
@@ -73,21 +122,45 @@ remote_auth_hint() {
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() {
# args: status_code message [extra_json_blob]
local code="$1"
local msg="$2"
local extra="${3:-{\}}"
local ts
local code="$1" msg="$2${ST_NOTE}" ts last_push=""
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]
[ -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:
extra_obj = json.loads(extra) if extra else {}
held = [h for h in json.loads(held) if isinstance(h, dict)]
except Exception:
extra_obj = {}
data = {"status": code, "message": msg, "ts": ts, **extra_obj}
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")
@@ -105,38 +178,73 @@ sync_active() {
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
# 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 <diff> exit 0 clean, 3 hit (prints the
# family names); anything else is
# a scanner failure (fail closed)
# secret_scan attribute <diff> <names> <out> 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
# "<family>\t<path>" 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,}')),
('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')),
('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.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)
"
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.
@@ -241,15 +349,24 @@ migrate_legacy_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}"
python3 - "$GSTACK_HOME" "$QUEUE_DIR" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" "$class_file" "$snapshot_file" "$transcripts_ok" <<'PYEOF'
import sys, json, os, fnmatch, glob
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 = sys.argv[1:10]
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:
@@ -344,7 +461,17 @@ def mode_allows(cls, mode):
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
@@ -355,7 +482,8 @@ if privacy_corrupt:
classified["retained"] = sorted(queue_paths)
queue_paths = set()
for p in sorted(queue_paths):
if p in skip_lines:
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.
@@ -382,11 +510,34 @@ for p in sorted(queue_paths):
if not os.path.exists(os.path.join(gstack_home, p)):
classified["dropped"]["missing"].append(p)
continue
final.append(p)
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)
@@ -484,6 +635,10 @@ 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):
@@ -510,11 +665,21 @@ for name in lines(snapshot_file):
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)
@@ -547,44 +712,218 @@ 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 ("<family>\t<path>"), $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 <path>), 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
}
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
# 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).
@@ -647,7 +986,7 @@ subcmd_once() {
# 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
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.
@@ -667,6 +1006,7 @@ subcmd_once() {
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
@@ -683,16 +1023,25 @@ subcmd_once() {
# 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
write_status "idle" "queue empty"
exit 0
fi
local paths_file class_file snapshot_file
paths_file=$(mktemp "${TMPDIR:-/tmp}/brain-sync-paths.XXXXXX") || { rm -rf "$lock_dir" 2>/dev/null; write_status "error" "mktemp failed"; exit 1; }
class_file=$(mktemp "${TMPDIR:-/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 "${TMPDIR:-/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
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
@@ -704,7 +1053,7 @@ subcmd_once() {
[ -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" > "$paths_file"; then
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
@@ -718,25 +1067,66 @@ subcmd_once() {
write_status "idle" "no stageable changes${summary:+ ($summary)}"
exit 0
fi
local n_stageable
n_stageable=$(grep -c . "$paths_file" 2>/dev/null || true)
# 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"
# 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
# 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 <path> OR edit the content."
write_status "blocked" "$hint"
echo "BRAIN_SYNC: blocked: $scan_out" >&2
# 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
@@ -755,22 +1145,23 @@ subcmd_once() {
exit 1
fi
# Commit with template message.
local n ts
# 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"
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
}
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 "blocked" "push blocked: unpushed commits hold transcript pages outside your transcript consent; commit retained locally"; finalize_queue "$snapshot_file" "$class_file" "$paths_file"; exit 0; }
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) || {
@@ -799,7 +1190,7 @@ subcmd_once() {
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"
write_status "$ok_code" "pushed $n file(s) after rebase"
exit 0
fi
fi
@@ -813,7 +1204,7 @@ subcmd_once() {
# 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)"
write_status "$ok_code" "pushed $n file(s)"
exit 0
}
@@ -827,7 +1218,9 @@ subcmd_status() {
# 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 ' ')
# 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 ' ') ))
@@ -842,7 +1235,7 @@ subcmd_status() {
subcmd_skip_file() {
local path="${1:-}"
if [ -z "$path" ]; then
echo "Usage: gstack-brain-sync --skip-file <path>" >&2
echo "Usage: $SELF_BIN --skip-file <path>" >&2
exit 1
fi
mkdir -p "$GSTACK_HOME"
@@ -853,7 +1246,83 @@ subcmd_skip_file() {
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)"
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 <path>" >&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() {
@@ -999,10 +1468,12 @@ 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 ;;
--help|-h)
sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'
awk 'NR > 1 && /^# Heredoc delivery guard/ { exit } NR > 1' "$0" | sed 's/^# \{0,1\}//'
;;
*)
echo "Unknown subcommand: $1" >&2
+15
View File
@@ -56,6 +56,17 @@ fi
PLIST_PATH="$HOME/Library/LaunchAgents/com.gstack.brain-sync.plist"
GBRAIN_CONFIG="$HOME/.gbrain/config.json"
# Git writes to $GSTACK_HOME (worktree add/prune/remove, the daily advance)
# take the drain lock every gstack writer shares. A caller that already holds
# it (the drain's daily advance, gstack-brain-restore) passes it down.
. "$SCRIPT_DIR/gstack-brain-lock.sh"
trap 'gstack_brain_lock_release' EXIT
take_drain_lock() {
[ -d "$GSTACK_HOME/.git" ] || return 0
gstack_brain_lock_acquire "$GSTACK_HOME" wait \
|| die "another gstack sync (pid ${_GSTACK_BRAIN_LOCK_HOLDER:-unknown}) is using $GSTACK_HOME; re-run when it finishes" 1
}
# ---- arg parse ----
MODE="wireup"
STRICT=0
@@ -285,10 +296,12 @@ do_wireup() {
# Capture ensure_worktree's return code explicitly. `$?` after `||` reflects
# the LAST command in the function under set -e, which is unreliable when the
# function has multiple internal exit paths.
take_drain_lock
set +e
ensure_worktree
ew_rc=$?
set -e
gstack_brain_lock_release
case "$ew_rc" in
0) : ;; # success
2)
@@ -403,6 +416,7 @@ do_advance_only() {
exit 0
fi
local sha cur
take_drain_lock
sha=$(git -C "$GSTACK_HOME" rev-parse HEAD 2>/dev/null) || { warn "advance-only: cannot read parent HEAD"; exit 0; }
cur=$(git -C "$WORKTREE" rev-parse HEAD 2>/dev/null || echo "")
if [ "$cur" = "$sha" ]; then
@@ -426,6 +440,7 @@ do_uninstall() {
fi
if [ -d "$WORKTREE/.git" ] || [ -f "$WORKTREE/.git" ]; then
take_drain_lock
git -C "$GSTACK_HOME" worktree remove --force "$WORKTREE" 2>/dev/null \
|| safe_rm_worktree "$WORKTREE"
fi
+10 -1
View File
@@ -320,12 +320,18 @@ if [ -d "$_GH/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
# fail-closed (same wiring as bin/gstack-brain-sync's fetch/push).
. "$_BIN/gstack-egress-lib.sh" 2>/dev/null || true
_PULL_HOST=$(cd "$_GH" 2>/dev/null && git remote get-url origin 2>/dev/null | sed -E 's|^[a-z+]+://([^/@]*@)?([^/:]+).*|\2|; s|^([^@]+@)?([^:]+):.*|\2|' | head -1)
if command -v _receipted_git >/dev/null 2>&1; then
# The merge writes the artifacts repo's git state, so it takes the drain
# lock every gstack writer shares; when a drain holds it, the pull waits
# for the next start (no stamp).
. "$_BIN/gstack-brain-lock.sh" 2>/dev/null || true
if command -v _receipted_git >/dev/null 2>&1 && command -v gstack_brain_lock_acquire >/dev/null 2>&1 \
&& gstack_brain_lock_acquire "$_GH" try; then
# Non-interactive + slow-network bounded (same guards as gstack-update-check):
# a hung remote or a credential prompt must not stall the preamble.
( cd "$_GH" && GSTACK_HOME="$_GH" GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \
_receipted_git closed brain-sync "${_PULL_HOST:-unknown}" curated-memory-git-fetch "artifacts_sync_mode!=off" \
git fetch origin >/dev/null 2>&1 && git merge --ff-only "origin/$(git rev-parse --abbrev-ref HEAD)" >/dev/null 2>&1 ) || true
gstack_brain_lock_release
# Stamp only when the receipted path actually ran — a missing egress lib
# must surface as a retry next start, not a silent 24h suppression.
echo "$_BRAIN_NOW" > "$_BRAIN_LAST_PULL_FILE"
@@ -352,6 +358,9 @@ elif [ -d "$_GH/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
[ -f "$_GH/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(head -1 "$_GH/.brain-last-push" 2>/dev/null | tr -cd 'A-Za-z0-9._:+-' || echo never)
_BRAIN_LAST_PUSH=${_BRAIN_LAST_PUSH:-never}
echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH"
# A stuck sync (scanner hold, failed push or commit, no push or no drain for
# 24 hours) prints fixed attention lines; a healthy sync prints none.
"$_BRAIN_SYNC_BIN" --attention 2>/dev/null | grep '^ARTIFACTS_SYNC: attention: ' || true
else
echo "ARTIFACTS_SYNC: off"
fi
+8 -7
View File
@@ -63,14 +63,15 @@ If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -178,14 +178,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -181,14 +181,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -180,14 +180,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -179,14 +179,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -204,14 +204,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -185,14 +185,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -182,14 +182,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -198,14 +198,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -184,14 +184,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -66,14 +66,15 @@ If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+42 -18
View File
@@ -29,33 +29,57 @@ gstack-config set artifacts_sync_mode_prompted true
---
## `BRAIN_SYNC: blocked: <pattern-family>:<snippet>`
## `ARTIFACTS_SYNC: attention: ...` at skill start
**Problem.** Sync stopped because the secret scanner detected credential-shaped
content in a staged file. The queue is preserved; nothing was pushed.
**Problem.** The artifacts sync is stuck or partly stuck. Skill start prints
one fixed line per problem; `ok` and `idle` print nothing. Every line names
the absolute path of `gstack-brain-sync`; run it with `--status` to see the
cause, the held files and the fix.
**Cause.** One of the pre-commit secret patterns matched the file contents —
likely an AWS key, GitHub token, OpenAI key, PEM block, JWT, or bearer token
embedded in JSON.
| State | Trigger | Attention line (after `ARTIFACTS_SYNC: attention: `) | Fix |
|---|---|---|---|
| `held` | The secret scan held back flagged files and synced the rest | `status=held held=<N>. The secret scan is holding back <N> file(s); everything else still syncs. See which files and how to fix them: <bin> --status` | Edit the file so the scan no longer matches, or `<bin> --skip-file <path>` (permanent; undo with `--unskip-file <path>`) |
| `blocked` | A re-scan still flagged after the hold, so nothing was committed | `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` | Edit or skip the files `--status` names, or clear the batch with `<bin> --drop-queue --yes` |
| `push_failed` | A commit is saved locally but the push failed | `status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: <bin> --status` | The fix `--status` names (usually `gh auth status`) |
| `error` | The drain stopped before committing and kept the queue | `status=error. The last sync stopped before committing; the queue is kept and retried. See the cause: <bin> --status` | The cause `--status` names: a git index lock, a commit hook, a `git add` failure |
| `unknown` | The status file holds a status code outside this table | `status=unknown. The sync status file is unreadable. See it: <bin> --status` | Run `<bin> --once` to rewrite it |
| stale-push | No push for 24 hours while the drain's `drainable` count is above zero | `stale-push. Files are ready to sync but nothing has been pushed for over 24 hours. See why: <bin> --status` | The waiting reason `--status` names (a coupled group waiting for a consistent generation, a `git add` failure) |
| stale-drain | `last_drain_at` older than 24 hours while the queue holds records | `stale-drain. The sync has not run for over 24 hours while files wait in the queue. See why: <bin> --status` | If no gstack process is running, remove `~/.gstack/.brain-sync.lock.d`, then run `<bin> --once` |
**Fix (three options).**
`<bin>` is the absolute path, for example
`~/.claude/skills/gstack/bin/gstack-brain-sync`. The source of truth is the
usage header of `bin/gstack-brain-sync`; skill start's tests are generated
from it.
1. **If it's a real secret**: edit the offending file to remove the secret,
then re-run any skill to retry sync.
**Held files.** When the secret scan flags a file, only that file is held
back (with any file coupled to it, such as a decision log and its active
snapshot); everything else syncs. The held file stays queued and is
re-scanned on every sync, so editing it clears the hold automatically.
`--status` lists each held file under `held`, with the scanner rule that
matched (never the matched text), the files held with it (`dependents`) and
both fixes:
2. **If the pattern is a false positive** (e.g., your learning contains a
GitHub token pattern in an example string that you *want* to publish):
1. **If it's a real secret**: edit the file to remove it. The next skill run
re-scans and syncs it.
2. **If the match is a false positive** you never want synced:
```bash
gstack-brain-sync --skip-file <path>
~/.claude/skills/gstack/bin/gstack-brain-sync --skip-file <path>
```
This permanently excludes the path from future syncs.
3. **If you want to abandon this sync batch entirely** (start fresh):
This permanently excludes the path from future syncs (for a coupled file,
the files coupled with it stop too). To undo it and queue the path again:
```bash
gstack-brain-sync --drop-queue --yes
~/.claude/skills/gstack/bin/gstack-brain-sync --unskip-file <path>
```
This clears the queue without committing. Future writes will re-populate
it normally.
3. **To abandon the whole batch** (start fresh):
```bash
~/.claude/skills/gstack/bin/gstack-brain-sync --drop-queue --yes
```
**Git index lock.** A git process killed mid-write leaves
`~/.gstack/.git/index.lock`. The sync removes it once it is older than 10
minutes, because every gstack writer to `~/.gstack` takes the same sync lock
first. A younger lock means a git process is running there; the sync reports
`error` and retries at the next skill start.
---
+18 -12
View File
@@ -90,11 +90,15 @@ On machine B:
gstack-brain-sync --status
```
Shows: last successful push, pending queue depth, any sync blocks, and the
current privacy mode.
Shows: the status (`ok`, `idle`, `held`, `blocked`, `push_failed`,
`error`), files held back by the secret scan with their fixes, the
`drainable` count, the last sync and push times, pending queue depth, and
the current privacy mode.
Every skill run prints a `BRAIN_SYNC:` line near the top of the preamble
output. Scan it for problems.
Every skill run prints an `ARTIFACTS_SYNC:` line near the top of the
preamble output. When the sync is stuck it adds an
`ARTIFACTS_SYNC: attention:` line; the table of those lines and their fixes
is in [gbrain-sync-errors.md](gbrain-sync-errors.md#artifacts_sync-attention--at-skill-start).
## Privacy modes in detail
@@ -122,20 +126,22 @@ your machine. Blocked patterns include:
- JWTs (`eyJ…`)
- Bearer tokens in JSON (`"authorization": "…"`, `"api_key": "…"`, etc.)
If a scan hits, sync stops, the queue is preserved, and your preamble
prints:
If a scan hits, only the flagged file is held back (with any file coupled
to it); everything else syncs. The held file stays queued and is re-scanned
at every sync, and skill start prints:
```
BRAIN_SYNC: blocked: <pattern-family>:<snippet>
ARTIFACTS_SYNC: attention: status=held held=1. The secret scan is holding back 1 file(s); everything else still syncs. See which files and how to fix them: <bin> --status
```
To remediate:
1. Review the offending file.
2. If the match is a false positive on content you explicitly want to
sync, run `gstack-brain-sync --skip-file <path>` to permanently
exclude that path.
3. Otherwise, edit the file to remove the secret and re-run any skill.
1. Run `gstack-brain-sync --status` (the line prints its absolute path). Each
entry under `held` names the file, the scanner rule and both fixes.
2. If the match is a false positive on content you never want synced, run
`gstack-brain-sync --skip-file <path>` to permanently exclude that path;
`gstack-brain-sync --unskip-file <path>` undoes it.
3. Otherwise, edit the file to remove the secret; the next skill run syncs it.
There's a defense-in-depth hook at `~/.gstack/.git/hooks/pre-commit` that
runs the same scan if you manually `git commit` against the repo.
+8 -7
View File
@@ -184,14 +184,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -182,14 +182,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -180,14 +180,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -219,14 +219,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -182,14 +182,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -184,14 +184,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -185,14 +185,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -188,14 +188,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -182,14 +182,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -177,14 +177,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -179,14 +179,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -180,14 +180,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -104,14 +104,15 @@ If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -225,14 +225,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -65,14 +65,15 @@ If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -181,14 +181,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -206,14 +206,15 @@ Before emitting a tool or prose decision brief, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -206,14 +206,15 @@ Before emitting a tool or prose decision brief, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -178,14 +178,15 @@ Before emitting a tool or prose decision brief, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -222,14 +222,15 @@ Before emitting a tool or prose decision brief, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -190,14 +190,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -180,14 +180,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -184,14 +184,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -198,14 +198,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -182,14 +182,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -62,14 +62,15 @@ If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
@@ -20,14 +20,15 @@ export function generateBrainSyncBlock(ctx: TemplateContext): string {
const isBrainHost = ctx.host === 'gbrain' || ctx.host === 'hermes';
return `## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer \`gbrain\` over Grep;
\`ARTIFACTS_SYNC:\` reports sync health (\`off\`, \`mode=... | queue=N\`,
\`remote-mode\`, or a restore hint naming \`gstack-brain-restore\`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer \`gbrain\` over Grep. \`ARTIFACTS_SYNC:\` reports sync health
(\`off\`, \`mode=... | queue=N\`, \`remote-mode\`, or a \`gstack-brain-restore\`
hint). On an \`attention:\` line, tell the user in one sentence what
it says and the command it names, then continue.
${isBrainHost ? `If output shows \`ARTIFACTS_SYNC: artifacts repo detected\`, offer \`gstack-brain-restore\` via AskUserQuestion; otherwise continue.
` : ''}The one-time privacy stop-gate (artifacts-sync consent) arrives as a
\`GSTACK_INSTRUCTION\` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.`;
` : ''}The one-time privacy stop-gate arrives as a \`GSTACK_INSTRUCTION\` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.`;
}
+8 -7
View File
@@ -61,14 +61,15 @@ If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -181,14 +181,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -180,14 +180,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -182,14 +182,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -179,14 +179,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -180,14 +180,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -181,14 +181,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -176,14 +176,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+451 -3
View File
@@ -379,9 +379,15 @@ describe('gstack-brain-sync secret scan', () => {
// No new commit should have been created.
const log = git(['log', '--oneline']);
expect(log.stdout.split('\n').filter(Boolean).length).toBeLessThanOrEqual(3);
// Status file should report blocked.
const status = JSON.parse(fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8'));
expect(status.status).toBe('blocked');
// The flagged file is held back on its own (status held, not blocked):
// named with its scanner rule, never the matched text, and still queued.
const raw = fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8');
const status = JSON.parse(raw);
expect(status.status).toBe('held');
expect(status.held_count).toBe(1);
expect(status.held.map((h: any) => h.path)).toEqual(['projects/p/learnings.jsonl']);
expect(raw).not.toContain(content.slice(0, 12));
expect(spoolText()).toContain('projects/p/learnings.jsonl');
});
}
@@ -402,6 +408,448 @@ describe('gstack-brain-sync secret scan', () => {
});
});
// ---------------------------------------------------------------
// Per-file hold: a flagged file is held back on its own (with its coupled
// group) and stays queued, the rest syncs, and every failure that used to be
// silent lands in the status file. Contributor regression tests from
// PR #3055 (@v639dragoon) are adapted here to the rewritten drain.
// ---------------------------------------------------------------
describe('gstack-brain-sync per-file hold', () => {
const FLAGGED = 'projects/p/learnings.jsonl';
const CLEAN = 'projects/p/ceo-plans/clean.md';
const LOG = 'projects/p/decisions.jsonl';
const SNAP = 'projects/p/decisions.active.json';
const TOKEN = 'ghp_abcdefghij1234567890abcdef1234567890';
const SELF = path.join(BIN, 'gstack-brain-sync');
const statusRaw = () => fs.readFileSync(path.join(tmpHome, '.brain-sync-status.json'), 'utf-8');
const statusJson = () => JSON.parse(statusRaw());
const remoteFiles = () =>
spawnSync('git', ['--git-dir=' + bareRemote, 'ls-tree', '-r', '--name-only', 'main'], { encoding: 'utf-8', timeout: 30_000 }).stdout;
const remoteLog = () =>
spawnSync('git', ['--git-dir=' + bareRemote, 'log', '--oneline'], { encoding: 'utf-8', timeout: 30_000 }).stdout;
const remoteShow = (p: string) =>
spawnSync('git', ['--git-dir=' + bareRemote, 'show', `main:${p}`], { encoding: 'utf-8', timeout: 30_000 });
const staged = () => git(['diff', '--cached', '--name-only']).stdout.trim();
const drain = (env: Record<string, string> = {}) => run(['gstack-brain-sync', '--once'], { env });
function init(mode = 'full') {
run(['gstack-artifacts-init', '--remote', bareRemote]);
run(['gstack-config', 'set', 'artifacts_sync_mode', mode]);
fs.mkdirSync(path.join(tmpHome, 'projects/p/ceo-plans'), { recursive: true });
}
function write(p: string, body: string, enqueue = true) {
fs.mkdirSync(path.dirname(path.join(tmpHome, p)), { recursive: true });
fs.writeFileSync(path.join(tmpHome, p), body);
if (enqueue) run(['gstack-brain-enqueue', p]);
}
const decide = (id: string) => JSON.stringify({ id, kind: 'decide', decision: `d ${id}`, scope: 'repo', date: `2026-10-0${id.length}`, source: 'user' });
function writeDecisions(ids: string[], snapIds = ids, snapExtra = '') {
write(SNAP, JSON.stringify(snapIds.map((id) => ({ id, kind: 'decide', note: snapExtra }))), false);
write(LOG, ids.map(decide).join('\n') + '\n');
}
/** A PATH shim in front of the real git: logs every call, optionally
* injects one fault. Fault injection reaches the drain's fail-closed paths,
* which a correct git never takes. */
function gitShim(fault = ''): { env: Record<string, string>; calls: () => string[] } {
const dir = fs.mkdtempSync(path.join(tmpHome, 'shim-'));
const real = spawnSync('sh', ['-c', 'command -v git'], { encoding: 'utf-8', timeout: 30_000 }).stdout.trim();
const log = path.join(dir, 'calls.log');
fs.writeFileSync(path.join(dir, 'git'), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${log}'\n${fault}\nexec '${real}' "$@"\n`, { mode: 0o755 });
return {
env: { PATH: `${dir}${path.delimiter}${process.env.PATH}` },
calls: () => (fs.existsSync(log) ? fs.readFileSync(log, 'utf-8').split('\n').filter(Boolean) : []),
};
}
test('one flagged file is held back; the clean file still syncs and status is held', () => {
init();
write(FLAGGED, `{"gh":"${TOKEN}"}\n`);
write(CLEAN, '# a plan\n');
expect(drain().status).toBe(0);
expect(remoteFiles()).toContain(CLEAN);
expect(remoteFiles()).not.toContain(FLAGGED);
expect(remoteLog()).toMatch(/sync: 1 file/);
expect(staged()).toBe('');
expect(spoolText()).toContain(FLAGGED);
expect(spoolText()).not.toContain(CLEAN);
const s = statusJson();
expect(s.status).toBe('held');
expect(s.held_count).toBe(1);
expect(s.drainable).toBe(0);
expect(s.last_push_at).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/);
expect(s.last_drain_at).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/);
expect(s.held).toHaveLength(1);
expect(s.held[0].path).toBe(FLAGGED);
expect(s.held[0].rule).toBe('github-token');
expect(s.held[0].dependents).toEqual([]);
// Both fixes, with the absolute helper path; skip is permanent and reversible.
expect(s.held[0].fixes[0]).toContain(`edit ${FLAGGED}`);
expect(s.held[0].fixes[1]).toContain(`${SELF} --skip-file ${FLAGGED}`);
expect(s.held[0].fixes[1]).toContain('permanently');
expect(s.held[0].fixes[1]).toContain(`${SELF} --unskip-file ${FLAGGED}`);
// The matched text appears nowhere in the status.
expect(statusRaw()).not.toContain('ghp_');
// --status prints the held array.
expect(run(['gstack-brain-sync', '--status']).stdout).toContain(`"path": "${FLAGGED}"`);
});
test('a held file is re-scanned every drain and clears once its content is edited', () => {
init();
write(FLAGGED, `{"gh":"${TOKEN}"}\n`);
drain();
drain();
expect(statusJson().status).toBe('held');
expect(spoolFiles()).toHaveLength(1);
write(FLAGGED, '{"gh":"rotated and removed"}\n');
drain();
const s = statusJson();
expect(s.status).toBe('ok');
expect(s.held).toEqual([]);
expect(s.held_count).toBe(0);
expect(remoteFiles()).toContain(FLAGGED);
expect(spoolFiles()).toEqual([]);
});
test('--skip-file clears a hold permanently and --unskip-file resumes syncing', () => {
init();
write(FLAGGED, `{"gh":"${TOKEN}"}\n`);
drain();
expect(run(['gstack-brain-sync', '--skip-file', FLAGGED]).stdout).toContain(`--unskip-file ${FLAGGED}`);
drain();
expect(statusJson().status).toBe('idle');
expect(statusJson().held_count).toBe(0);
expect(spoolFiles()).toEqual([]);
expect(remoteFiles()).not.toContain(FLAGGED);
write(FLAGGED, '{"gh":"clean now"}\n', false);
const r = run(['gstack-brain-sync', '--unskip-file', FLAGGED]);
expect(r.stdout).toContain(`removed from skip list: ${FLAGGED}`);
expect(fs.readFileSync(path.join(tmpHome, '.brain-skip.txt'), 'utf-8')).not.toContain(FLAGGED);
expect(spoolText()).toContain(FLAGGED);
drain();
expect(statusJson().status).toBe('ok');
expect(remoteFiles()).toContain(FLAGGED);
expect(run(['gstack-brain-sync', '--unskip-file', FLAGGED]).stdout).toContain('not in skip list');
});
test('undecodable bytes next to a secret cannot pass the scan as clean', () => {
init();
fs.writeFileSync(path.join(tmpHome, FLAGGED), Buffer.concat([Buffer.from('{"bin":"'), Buffer.from([0xff, 0xfe, 0xc3]), Buffer.from(`","gh":"${TOKEN}"}\n`)]));
run(['gstack-brain-enqueue', FLAGGED]);
write(CLEAN, '# a plan\n');
drain();
expect(remoteFiles()).not.toContain(FLAGGED);
expect(remoteFiles()).toContain(CLEAN);
expect(statusJson().status).toBe('held');
});
test('a re-scan that still flags after the hold falls back to blocked: nothing committed, queue kept', () => {
init();
write(FLAGGED, `{"gh":"${TOKEN}"}\n`);
write(CLEAN, '# a plan\n');
const before = git(['rev-list', '--count', 'HEAD']).stdout.trim();
// git "succeeds" at unstaging the held file without doing it.
const shim = gitShim(`case "$*" in *"reset -q HEAD --pathspec-from-file=-"*) cat >/dev/null; exit 0 ;; esac`);
expect(drain(shim.env).status).toBe(0);
const s = statusJson();
expect(s.status).toBe('blocked');
expect(s.message).toContain('re-scan');
expect(statusRaw()).not.toContain('ghp_');
expect(git(['rev-list', '--count', 'HEAD']).stdout.trim()).toBe(before);
expect(staged()).toBe('');
expect(spoolText()).toContain(FLAGGED);
expect(spoolText()).toContain(CLEAN);
expect(remoteFiles()).not.toContain(CLEAN);
});
test('findings the scanner cannot attribute to files block the drain (fail closed)', () => {
init();
write(CLEAN, '# a plan\n');
const shim = gitShim(`case "$*" in *"--name-only"*) '${spawnSync('sh', ['-c', 'command -v git'], { encoding: 'utf-8', timeout: 30_000 }).stdout.trim()}' "$@"; printf 'extra\\0'; exit 0 ;; esac`);
drain(shim.env);
expect(statusJson().status).toBe('blocked');
expect(staged()).toBe('');
expect(remoteFiles()).not.toContain(CLEAN);
expect(spoolText()).toContain(CLEAN);
});
_test('1,000 queued paths are attributed in one scanner pass and staged in one add', () => {
init();
for (let i = 0; i < 1000; i++) {
const flagged = i % 400 === 7;
fs.writeFileSync(path.join(tmpHome, `projects/p/ceo-plans/plan-${i}.md`), flagged ? `token ${TOKEN}\n` : `# plan ${i}\n`);
seedSpool(`{"file":"projects/p/ceo-plans/plan-${i}.md","ts":"t"}`);
}
const shim = gitShim();
expect(drain(shim.env).status).toBe(0);
const s = statusJson();
expect(s.status).toBe('held');
expect(s.held.map((h: any) => h.path).sort()).toEqual(
['projects/p/ceo-plans/plan-407.md', 'projects/p/ceo-plans/plan-7.md', 'projects/p/ceo-plans/plan-807.md'],
);
expect(remoteLog()).toMatch(/sync: 997 file/);
const calls = shim.calls();
expect(calls.filter((c) => / add /.test(` ${c} `)).length).toBe(1);
// attribution diff + name list + post-hold re-scan diff + empty-diff check
expect(calls.filter((c) => c.includes('diff --cached')).length).toBeLessThanOrEqual(4);
expect(spoolFiles()).toHaveLength(3);
}, 120_000);
test('a git add failure is reported, never swallowed', () => {
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root)
init();
write(CLEAN, '# a plan\n');
const unreadable = 'projects/p/ceo-plans/unreadable.md';
write(unreadable, '# cannot be read\n');
fs.chmodSync(path.join(tmpHome, unreadable), 0o000);
try {
expect(drain().status).toBe(0);
const s = statusJson();
expect(s.status).toBe('ok');
expect(s.message).toContain('git add failed for 1 path(s)');
expect(s.drainable).toBe(1);
expect(remoteLog()).toMatch(/sync: 1 file/);
expect(remoteFiles()).toContain(CLEAN);
expect(spoolText()).toContain(unreadable);
drain();
const s2 = statusJson();
expect(s2.status).toBe('error');
expect(s2.message).toContain('git add failed for all 1 path(s)');
expect(spoolText()).toContain(unreadable);
} finally {
fs.chmodSync(path.join(tmpHome, unreadable), 0o600);
}
});
test('duplicate records for a privacy-held path collapse to the newest one', () => {
init('artifacts-only');
write('projects/p/timeline.jsonl', '{"skill":"x","event":"started"}\n', false);
seedSpool('{"file":"projects/p/timeline.jsonl","ts":"a"}');
seedSpool('{"file":"projects/p/timeline.jsonl","ts":"b"}');
const newest = seedSpool('{"file":"projects/p/timeline.jsonl","ts":"c"}');
expect(drain().status).toBe(0);
expect(spoolFiles()).toEqual([newest]);
expect(statusJson().message).toContain('privacy-held retained');
expect(statusJson().drainable).toBe(0);
});
_test('--status counts a spool too deep for a shell glob', () => {
init();
fs.mkdirSync(spoolDir(), { recursive: true });
// 12,000 names of ~200 bytes overflow ARG_MAX on Linux (2 MiB) and macOS alike.
const N = 12_000;
const pad = 'd'.repeat(180);
for (let i = 0; i < N; i++) {
fs.writeFileSync(path.join(spoolDir(), `1700000000-1-${pad}${String(i).padStart(6, '0')}.json`), '{"file":"x"}\n');
}
expect(run(['gstack-brain-sync', '--status']).stdout).toContain(`"queue_depth":${N}`);
}, 60_000);
describe('git index lock and the shared drain lock', () => {
const indexLock = () => path.join(tmpHome, '.git', 'index.lock');
const age = (p: string, ms: number) => { const t = new Date(Date.now() - ms); fs.utimesSync(p, t, t); };
test('a fresh index lock is left alone: status error, queue kept', () => {
init();
write(CLEAN, '# a plan\n');
fs.writeFileSync(indexLock(), '');
expect(drain().status).toBe(0);
const s = statusJson();
expect(s.status).toBe('error');
expect(s.message).toContain('index lock');
expect(s.drainable).toBe(1);
expect(fs.existsSync(indexLock())).toBe(true);
expect(spoolText()).toContain(CLEAN);
expect(remoteFiles()).not.toContain(CLEAN);
});
test('an index lock older than 10 minutes is cleared under the drain lock and the queue syncs', () => {
init();
write(CLEAN, '# a plan\n');
fs.writeFileSync(indexLock(), '');
age(indexLock(), 11 * 60_000);
expect(drain().status).toBe(0);
expect(fs.existsSync(indexLock())).toBe(false);
expect(statusJson().status).toBe('ok');
expect(statusJson().message).toContain('stale git index lock');
expect(remoteFiles()).toContain(CLEAN);
});
test('an old index lock while another writer holds the drain lock is left alone', () => {
init();
write(CLEAN, '# a plan\n');
const lockDir = path.join(tmpHome, '.brain-sync.lock.d');
fs.mkdirSync(lockDir);
fs.writeFileSync(path.join(lockDir, 'pid'), `${process.pid}\n`);
fs.writeFileSync(indexLock(), '');
age(indexLock(), 60 * 60_000);
const statusBefore = fs.existsSync(path.join(tmpHome, '.brain-sync-status.json')) ? statusRaw() : null;
expect(drain().status).toBe(0);
expect(fs.existsSync(indexLock())).toBe(true);
expect(fs.existsSync(path.join(tmpHome, '.brain-sync-status.json')) ? statusRaw() : null).toBe(statusBefore);
expect(spoolText()).toContain(CLEAN);
expect(fs.readFileSync(path.join(lockDir, 'pid'), 'utf-8').trim()).toBe(String(process.pid));
});
_test('a concurrent writer: init waits for the drain lock, and gives up after the wait', async () => {
init();
const holder = Bun.spawn(['bash', '-c', `. '${path.join(BIN, 'gstack-brain-lock.sh')}'; gstack_brain_lock_acquire "$1" try || exit 9; echo held; sleep 3; gstack_brain_lock_release`, '_', tmpHome], { stdout: 'pipe' });
const reader = holder.stdout.getReader();
expect(new TextDecoder().decode((await reader.read()).value)).toContain('held');
// A drain skips while the lock is held; init with no wait budget refuses.
write(CLEAN, '# a plan\n');
expect(drain().status).toBe(0);
expect(spoolText()).toContain(CLEAN);
const refused = run(['gstack-artifacts-init', '--remote', bareRemote], { env: { GSTACK_BRAIN_LOCK_WAIT: '0' } });
expect(refused.status).toBe(1);
expect(refused.stderr).toContain('another gstack sync');
// With a wait budget, init runs once the holder releases.
const waited = Bun.spawn([path.join(BIN, 'gstack-artifacts-init'), '--remote', bareRemote], {
env: { ...process.env, HOME: tmpHome, GSTACK_HOME: tmpHome, GSTACK_BRAIN_LOCK_WAIT: '20' }, stdout: 'pipe', stderr: 'pipe',
});
expect(await waited.exited).toBe(0);
expect(await holder.exited).toBe(0);
expect(fs.existsSync(path.join(tmpHome, '.brain-sync.lock.d'))).toBe(false);
expect(drain().status).toBe(0);
expect(remoteFiles()).toContain(CLEAN);
}, 60_000);
});
describe('commit failures keep the queue', () => {
for (const [name, hook] of [['a rejecting pre-commit hook', 'pre-commit'], ['a commit failure after staging (commit-msg)', 'commit-msg']]) {
test(name, () => {
init();
write(CLEAN, '# a plan\n');
const hookPath = path.join(tmpHome, '.git', 'hooks', hook);
const original = fs.existsSync(hookPath) ? fs.readFileSync(hookPath) : null;
fs.writeFileSync(hookPath, '#!/bin/sh\necho "refusing this commit" >&2\nexit 1\n', { mode: 0o755 });
const before = git(['rev-list', '--count', 'HEAD']).stdout.trim();
expect(drain().status).toBe(0);
const s = statusJson();
expect(s.status).toBe('error');
expect(s.message).toContain('git commit failed');
expect(s.drainable).toBe(1);
expect(git(['rev-list', '--count', 'HEAD']).stdout.trim()).toBe(before);
expect(staged()).toBe('');
expect(spoolText()).toContain(CLEAN);
if (original) fs.writeFileSync(hookPath, original); else fs.rmSync(hookPath);
drain();
expect(statusJson().status).toBe('ok');
expect(remoteFiles()).toContain(CLEAN);
});
}
});
test('a push that keeps failing stays push_failed on the empty-queue runs after it', () => {
init();
write(CLEAN, '# a plan\n');
drain();
const hook = path.join(bareRemote, 'hooks', 'pre-receive');
fs.writeFileSync(hook, '#!/bin/sh\necho "403 forbidden" >&2\nexit 1\n', { mode: 0o755 });
write(CLEAN, '# a plan, revised\n');
drain();
expect(statusJson().status).toBe('push_failed');
// The queue is empty now and the detector retry is throttled: still failed.
drain();
expect(spoolFiles()).toEqual([]);
expect(statusJson().status).toBe('push_failed');
expect(statusJson().message).toContain('not pushed yet');
});
describe('coupled groups (decision log and its active snapshot)', () => {
test('a clean update publishes the group in one commit', () => {
init();
writeDecisions(['a']);
drain();
writeDecisions(['a', 'bb']);
drain();
expect(statusJson().status).toBe('ok');
const last = spawnSync('git', ['--git-dir=' + bareRemote, 'show', '--name-only', '--format=', 'main'], { encoding: 'utf-8', timeout: 30_000 }).stdout;
expect(last).toContain(LOG);
expect(last).toContain(SNAP);
expect(remoteShow(SNAP).stdout).toContain('"bb"');
});
test('a generation whose snapshot does not match its log waits, then publishes whole', () => {
init();
writeDecisions(['a']);
drain();
// The log gained a decision; the snapshot is not rebuilt yet.
writeDecisions(['a', 'bb'], ['a']);
drain();
const s = statusJson();
expect(s.status).toBe('idle');
expect(s.drainable).toBe(2);
expect(s.message).toContain('consistent generation');
expect(remoteShow(LOG).stdout).not.toContain('"bb"');
expect(spoolText()).toContain(LOG);
writeDecisions(['a', 'bb']);
drain();
expect(statusJson().status).toBe('ok');
expect(remoteShow(LOG).stdout).toContain('"bb"');
expect(remoteShow(SNAP).stdout).toContain('"bb"');
});
test('a held snapshot holds its log; a second machine restores a consistent generation', async () => {
init();
writeDecisions(['a']);
drain();
writeDecisions(['a', 'bb'], ['a', 'bb'], TOKEN);
drain();
const s = statusJson();
expect(s.status).toBe('held');
expect(s.held).toHaveLength(1);
expect(s.held[0].path).toBe(SNAP);
expect(s.held[0].dependents).toEqual([LOG]);
expect(s.held_count).toBe(2);
expect(s.held[0].fixes[1]).toContain('the files coupled with it');
expect(remoteShow(LOG).stdout).not.toContain('"bb"');
expect(spoolText()).toContain(LOG);
const machineB = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-machineB-'));
try {
expect(run(['gstack-brain-restore', bareRemote], { env: { GSTACK_HOME: machineB } }).status).toBe(0);
const { decisionPaths, readSnapshot, readEvents, computeActive } = await import('../lib/gstack-decision');
const paths = decisionPaths('p', machineB);
const fromSnapshot = readSnapshot(paths).map((d) => d.id).sort();
const fromLog = computeActive(readEvents(paths)).map((d) => d.id).sort();
expect(fromSnapshot).toEqual(['a']);
expect(fromLog).toEqual(fromSnapshot);
} finally {
fs.rmSync(machineB, { recursive: true, force: true });
}
});
test('skipping the blocking path stops the whole group; unskipping publishes it whole', () => {
init();
writeDecisions(['a']);
drain();
writeDecisions(['a', 'bb'], ['a', 'bb'], TOKEN);
drain();
expect(statusJson().status).toBe('held');
run(['gstack-brain-sync', '--skip-file', SNAP]);
drain();
expect(statusJson().held_count).toBe(0);
expect(spoolFiles()).toEqual([]);
expect(remoteShow(LOG).stdout).not.toContain('"bb"');
// Another decision while the group is skipped still never publishes the log alone.
writeDecisions(['a', 'bb', 'ccc'], ['a', 'bb', 'ccc'], TOKEN);
drain();
expect(remoteShow(LOG).stdout).not.toContain('"ccc"');
writeDecisions(['a', 'bb', 'ccc'], ['a', 'bb', 'ccc'], 'clean');
run(['gstack-brain-sync', '--unskip-file', SNAP]);
drain();
expect(statusJson().status).toBe('ok');
const last = spawnSync('git', ['--git-dir=' + bareRemote, 'show', '--name-only', '--format=', 'main'], { encoding: 'utf-8', timeout: 30_000 }).stdout;
expect(last).toContain(LOG);
expect(last).toContain(SNAP);
expect(remoteShow(LOG).stdout).toContain('"ccc"');
expect(remoteShow(SNAP).stdout).toContain('"ccc"');
});
});
});
// ---------------------------------------------------------------
// Egress receipt gate: receipt-before-commit, queue intact on refusal
// ---------------------------------------------------------------
+8 -7
View File
@@ -182,14 +182,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+8 -7
View File
@@ -164,14 +164,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (gpt)
+8 -7
View File
@@ -166,14 +166,15 @@ Before calling AskUserQuestion, verify:
## Artifacts Sync (skill start)
The skill-start output above already ran artifacts sync. Act on its lines:
GBrain hint text (if present) tells you when to prefer `gbrain` over Grep;
`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,
`remote-mode`, or a restore hint naming `gstack-brain-restore`).
Skill-start already ran artifacts sync. GBrain hint text (if any) says
when to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health
(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`
hint). On an `attention:` line, tell the user in one sentence what
it says and the command it names, then continue.
The one-time privacy stop-gate (artifacts-sync consent) arrives as a
`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending
— fire it via AskUserQuestion exactly as the block instructs.
The one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block
from skill-start when consent is pending; fire it via AskUserQuestion
exactly as instructed.
## Model-Specific Behavioral Patch (claude)
+167
View File
@@ -434,6 +434,173 @@ describe('gstack-skill-start behavior', () => {
});
});
// ---------------------------------------------------------------
// ARTIFACTS_SYNC attention lines. The cases are generated from the sync
// status table in bin/gstack-brain-sync's usage header: every row needs a
// fixture, and each fixture must print exactly that row's line.
// ---------------------------------------------------------------
describe('ARTIFACTS_SYNC attention lines (sync status table)', () => {
const SYNC = path.join(ROOT, 'bin', 'gstack-brain-sync');
const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
const NOW = () => iso(Date.now());
const OLD = () => iso(Date.now() - 2 * 86_400_000);
type Row = { state: string; trigger: string; line: string; fix: string };
function statusTable(): Row[] {
const rows: Row[] = [];
for (const raw of fs.readFileSync(SYNC, 'utf-8').split('\n')) {
const head = raw.match(/^# ([a-z_-]+)\s+trigger: (.+)$/);
if (head) { rows.push({ state: head[1], trigger: head[2], line: '', fix: '' }); continue; }
const field = raw.match(/^#\s+(line|fix):\s+(.+)$/);
if (field && rows.length) rows[rows.length - 1][field[1] as 'line' | 'fix'] = field[2];
}
return rows;
}
const attention = (out: string) => out.split('\n').filter((l) => l.startsWith('ARTIFACTS_SYNC: attention: '));
/** An initialized sync home whose drain lock is held by this (live) test
* process, so skill start's merge and drain skip and the fixture's status
* file is what skill start reads. */
function syncHome(status: Record<string, unknown> | string | null, queued = 0): string {
const gh = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-attn-'));
execFileSync('git', ['init', '-q', '-b', 'main', gh], { timeout: 30_000, stdio: 'pipe' });
fs.writeFileSync(path.join(gh, 'config.yaml'), 'update_check: false\nartifacts_sync_mode: full\nartifacts_sync_mode_prompted: true\n');
fs.mkdirSync(path.join(gh, '.brain-sync.lock.d'));
fs.writeFileSync(path.join(gh, '.brain-sync.lock.d', 'pid'), `${process.pid}\n`);
if (status !== null) {
fs.writeFileSync(path.join(gh, '.brain-sync-status.json'), typeof status === 'string' ? status : JSON.stringify(status) + '\n');
}
fs.mkdirSync(path.join(gh, '.brain-queue.d'));
for (let i = 0; i < queued; i++) {
fs.writeFileSync(path.join(gh, '.brain-queue.d', `${Math.floor(Date.now() / 1000)}-1-q${i}.json`), '{"file":"projects/p/x.md"}\n');
}
return gh;
}
const status = (code: string, extra: Record<string, unknown> = {}) => ({
status: code, held_count: 0, drainable: 0, last_drain_at: NOW(), last_push_at: NOW(), ts: NOW(), held: [], message: 'm', ...extra,
});
const FIXTURES: Record<string, { status: () => Record<string, unknown>; queued?: number; n?: number }> = {
held: { status: () => status('held', { held_count: 2 }), n: 2 },
blocked: { status: () => status('blocked') },
push_failed: { status: () => status('push_failed') },
error: { status: () => status('error') },
unknown: { status: () => status('rebooting') },
'stale-push': { status: () => status('idle', { drainable: 3, last_push_at: OLD() }), queued: 3 },
'stale-drain': { status: () => status('ok', { last_drain_at: OLD() }), queued: 1 },
};
test('the table names every state skill start can report, each with a line and a fix', () => {
const rows = statusTable();
expect(rows.map((r) => r.state).sort()).toEqual(Object.keys(FIXTURES).sort());
// The docs carry the same table (one source of wording).
const docs = fs.readFileSync(path.join(ROOT, 'docs', 'gbrain-sync-errors.md'), 'utf-8');
for (const r of rows) {
expect(r.line).toStartWith('ARTIFACTS_SYNC: attention: ');
expect(r.line).toContain('<bin> --status');
expect(r.fix.length).toBeGreaterThan(0);
expect(docs).toContain('`' + r.line.slice('ARTIFACTS_SYNC: attention: '.length) + '`');
}
});
for (const row of statusTable()) {
test(`${row.state}: ${row.trigger}`, () => {
const fx = FIXTURES[row.state];
expect(fx).toBeDefined();
const gh = syncHome(fx.status(), fx.queued ?? 0);
try {
const expected = row.line.replaceAll('<bin>', SYNC).replaceAll('<N>', String(fx.n ?? 0));
expect(attention(runStart([], { GSTACK_HOME: gh }))).toEqual([expected]);
} finally {
fs.rmSync(gh, { recursive: true, force: true });
}
});
}
test('ok and idle print no attention line', () => {
for (const code of ['ok', 'idle']) {
const gh = syncHome(status(code), 1);
try {
const out = runStart([], { GSTACK_HOME: gh });
expect(out).toMatch(/^ARTIFACTS_SYNC: mode=full \| last_push=\S+ \| queue=\d+$/m);
expect(attention(out)).toEqual([]);
} finally {
fs.rmSync(gh, { recursive: true, force: true });
}
}
});
test('attention lines carry no status text, artifact path or bare helper name', () => {
const leak = 'ghp_abcdefghij1234567890abcdef1234567890';
const gh = syncHome(status('held', {
held_count: 7,
message: `secret pattern detected (github-token:${leak.slice(0, 30)}...) in projects/p/learnings.jsonl`,
held: [{ path: 'projects/p/learnings.jsonl', rule: 'github-token', dependents: [], fixes: ['gstack-brain-sync --skip-file projects/p/learnings.jsonl'] }],
last_drain_at: `${NOW()}"; echo pwned`,
}), 1);
try {
const lines = attention(runStart([], { GSTACK_HOME: gh }));
expect(lines.length).toBeGreaterThan(0);
const text = lines.join('\n');
expect(text).not.toContain('ghp_');
expect(text).not.toContain('projects/p');
expect(text).not.toContain('pwned');
// The only path is the absolute helper path; no bare gstack- command remains.
const stripped = text.replaceAll(SYNC, '<bin>');
expect(stripped).not.toContain('/');
expect(stripped).not.toContain('gstack-');
// A forged last_drain_at fails the timestamp clamp, which reads as a drain that never ran.
expect(lines).toContain(`ARTIFACTS_SYNC: attention: stale-drain. The sync has not run for over 24 hours while files wait in the queue. See why: ${SYNC} --status`);
} finally {
fs.rmSync(gh, { recursive: true, force: true });
}
});
test('a held file is reported at skill start while other files keep pushing; a privacy-held queue raises no alarm', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-sync-home-'));
const gh = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-sync-gh-'));
const remote = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-sync-remote-'));
const env = { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: gh };
const bin = (name: string, args: string[]) =>
execFileSync(path.join(ROOT, 'bin', name), args, { timeout: 30_000, encoding: 'utf-8', cwd: home, env, stdio: 'pipe' });
const remoteFiles = () => execFileSync('git', ['--git-dir=' + remote, 'ls-tree', '-r', '--name-only', 'main'], { encoding: 'utf-8', timeout: 30_000 });
try {
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', remote], { timeout: 30_000 });
bin('gstack-artifacts-init', ['--remote', remote]);
bin('gstack-config', ['set', 'artifacts_sync_mode', 'full']);
bin('gstack-config', ['set', 'artifacts_sync_mode_prompted', 'true']);
bin('gstack-config', ['set', 'update_check', 'false']);
fs.mkdirSync(path.join(gh, 'projects/p/ceo-plans'), { recursive: true });
fs.writeFileSync(path.join(gh, 'projects/p/learnings.jsonl'), '{"gh":"ghp_abcdefghij1234567890abcdef1234567890"}\n');
fs.writeFileSync(path.join(gh, 'projects/p/ceo-plans/clean.md'), '# a plan\n');
bin('gstack-brain-enqueue', ['projects/p/learnings.jsonl']);
bin('gstack-brain-enqueue', ['projects/p/ceo-plans/clean.md']);
const held = runStart([], env);
expect(held).toMatch(/^ARTIFACTS_SYNC: mode=full \| last_push=\S+ \| queue=\d+$/m);
expect(attention(held)).toEqual([
`ARTIFACTS_SYNC: attention: status=held held=1. The secret scan is holding back 1 file(s); everything else still syncs. See which files and how to fix them: ${path.join(ROOT, 'bin', 'gstack-brain-sync')} --status`,
]);
expect(held).not.toContain('ghp_');
expect(remoteFiles()).toContain('projects/p/ceo-plans/clean.md');
expect(remoteFiles()).not.toContain('projects/p/learnings.jsonl');
// Privacy-held records never count as drainable: no alarm, even with
// the last push two days old.
bin('gstack-brain-sync', ['--skip-file', 'projects/p/learnings.jsonl']);
bin('gstack-config', ['set', 'artifacts_sync_mode', 'artifacts-only']);
fs.writeFileSync(path.join(gh, 'projects/p/timeline.jsonl'), '{"skill":"x"}\n');
bin('gstack-brain-enqueue', ['projects/p/timeline.jsonl']);
fs.writeFileSync(path.join(gh, '.brain-last-push'), `${OLD()}\n`);
const quiet = runStart([], env);
expect(quiet).toMatch(/^ARTIFACTS_SYNC: mode=artifacts-only \| last_push=\S+ \| queue=\d+$/m);
expect(attention(quiet)).toEqual([]);
} finally {
for (const dir of [home, gh, remote]) fs.rmSync(dir, { recursive: true, force: true });
}
}, 60_000);
});
describe('gstack-skill-end', () => {
test('computes duration from --tel-start and reports the outcome', () => {
const start = Math.floor(Date.now() / 1000) - 7;