From 988fe8fe761bba0c792e33ac02e9a61240190ea6 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 7 Oct 2026 20:09:42 +0000 Subject: [PATCH 1/3] fix(brain-sync): one drain lock for every writer to the artifacts repo gstack-artifacts-init, gstack-brain-restore, gstack-gbrain-source-wireup and the skill-start merge ran git in $GSTACK_HOME without the drain's lock, so the drain could never tell an abandoned .git/index.lock from a live one. They now take the same mkdir lock (bin/gstack-brain-lock.sh): the drain and the skill-start merge skip when it is held, user-run writers wait for it (GSTACK_BRAIN_LOCK_WAIT, default 120 s), and a child of the holder (the drain's daily worktree advance, restore's wireup) inherits it by token. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-artifacts-init | 13 ++++- bin/gstack-brain-lock.sh | 89 +++++++++++++++++++++++++++++++++ bin/gstack-brain-restore | 11 +++- bin/gstack-gbrain-source-wireup | 15 ++++++ bin/gstack-skill-start | 8 ++- 5 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 bin/gstack-brain-lock.sh diff --git a/bin/gstack-artifacts-init b/bin/gstack-artifacts-init index a82f9e7a4..5963649aa 100755 --- a/bin/gstack-artifacts-init +++ b/bin/gstack-artifacts-init @@ -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' diff --git a/bin/gstack-brain-lock.sh b/bin/gstack-brain-lock.sh new file mode 100644 index 000000000..2f60ac942 --- /dev/null +++ b/bin/gstack-brain-lock.sh @@ -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 +# 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 +} diff --git a/bin/gstack-brain-restore b/bin/gstack-brain-restore index f81a14d13..4438c0a72 100755 --- a/bin/gstack-brain-restore +++ b/bin/gstack-brain-restore @@ -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%%[/:]*}" diff --git a/bin/gstack-gbrain-source-wireup b/bin/gstack-gbrain-source-wireup index 42fcea288..11248bf28 100755 --- a/bin/gstack-gbrain-source-wireup +++ b/bin/gstack-gbrain-source-wireup @@ -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 diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 9e4417943..6a922158f 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -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" From f5896acc2c7fcd7024eca4ad9a3e54761f120708 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 7 Oct 2026 20:10:04 +0000 Subject: [PATCH 2/3] fix(brain-sync): hold back only the flagged file, keep coupled artifacts whole One file with secret-shaped text used to block every push, silently. The drain now attributes scanner hits to files in one pass over the staged diff (split on its diff --git headers), holds back each flagged file with the rest of its coupled group, unstages it, and requires the remaining staged diff to re-scan clean; anything short of that falls back to a full unstage and status blocked. Held files stay queued and are re-scanned every drain. - Status file gains held_count, drainable, last_drain_at, last_push_at and held[] (path, scanner rule, dependents, both fixes); new status held. - Coupled groups come from an explicit map (decisions.jsonl and decisions.active.json): a queued member stages the whole group, a group stages only when its snapshot matches its log in the index, a skipped member stops the whole group. - --unskip-file undoes --skip-file and queues the path again. - An index lock older than 10 minutes is removed under the drain lock; a younger one, a failed git add, a failed commit (hooks included) keep the queue and report status error. A verified empty staged diff is the only "nothing to commit". git add runs once, batched. - The scanner reads bytes, so undecodable content no longer crashes it into a false clean; matched text never reaches the status file. - An empty-queue run with unpushed sync commits stays push_failed. - Privacy-held duplicate records collapse to one per path; --status counts a spool too deep for a shell glob. - --attention prints skill start's fixed attention lines from the sync status table in the usage header. Co-authored-by: v639dragoon Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-brain-sync | 701 +++++++++++++++++++++++++++++++++------- test/brain-sync.test.ts | 454 +++++++++++++++++++++++++- 2 files changed, 1037 insertions(+), 118 deletions(-) diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index 8c32a5eb0..bc7ac76d5 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -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

add

to ~/.gstack/.brain-skip.txt +# gstack-brain-sync --skip-file

permanently exclude

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

undo --skip-file and queue

again # gstack-brain-sync --drop-queue --yes clear queue without committing # gstack-brain-sync --discover-new scan allowlist dirs, enqueue changed files +# gstack-brain-sync --attention print skill start's attention lines # # 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. is this script's absolute path +# and the held count. Rows are read by test/gstack-skill-start.test.ts. +# +# held trigger: status=held: the secret scan held back flagged files and synced the rest +# line: ARTIFACTS_SYNC: attention: status=held held=. The secret scan is holding back file(s); everything else still syncs. See which files and how to fix them: --status +# fix: edit the file so the scan no longer matches, or --skip-file (permanent; undo with --unskip-file ) +# blocked trigger: status=blocked: a re-scan still flagged after the hold, so nothing was committed +# line: ARTIFACTS_SYNC: attention: status=blocked. The secret scan flagged a file it could not hold back on its own, so nothing is syncing. See the fix: --status +# fix: edit or skip the files named by --status, or clear the batch with --drop-queue --yes +# push_failed trigger: status=push_failed: a commit is saved locally but the push failed +# line: ARTIFACTS_SYNC: attention: status=push_failed. Synced files are committed locally but the push failed; it retries at skill start. See the cause: --status +# fix: the fix --status names (usually: gh auth status) +# error trigger: status=error: the drain stopped before committing and kept the queue +# line: ARTIFACTS_SYNC: attention: status=error. The last sync stopped before committing; the queue is kept and retried. See the cause: --status +# fix: the cause --status names (git index lock, commit hook, git add failure) +# unknown trigger: the status file holds a status code outside this table +# line: ARTIFACTS_SYNC: attention: status=unknown. The sync status file is unreadable. See it: --status +# fix: run --once to rewrite it +# stale-push trigger: no push for 24 hours while the drain's drainable count is above zero +# line: ARTIFACTS_SYNC: attention: stale-push. Files are ready to sync but nothing has been pushed for over 24 hours. See why: --status +# fix: the waiting reason --status names (a coupled group waiting for a consistent generation, a git add failure) +# stale-drain trigger: last_drain_at older than 24 hours while the queue directory holds records +# line: ARTIFACTS_SYNC: attention: stale-drain. The sync has not run for over 24 hours while files wait in the queue. See why: --status +# fix: if no gstack process is running, remove ~/.gstack/.brain-sync.lock.d, then run --once # # Env: # GSTACK_HOME — override ~/.gstack (aligns with writers). @@ -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 exit 0 clean, 3 hit (prints the +# family names); anything else is +# a scanner failure (fail closed) +# secret_scan attribute one pass: splits the diff on its +# `diff --git` headers, pairs each +# section with the NUL-separated +# names from `git diff --name-only +# -z` (same order), writes +# "\t" per flagged +# path; exit 0, or 4 when sections +# and names disagree +secret_scan() { + python3 - "$@" <<'PYEOF' +import re, sys patterns = [ ('aws-access-key', re.compile(r'AKIA[0-9A-Z]{16}')), - ('github-token', re.compile(r'\\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})')), - ('openai-key', re.compile(r'\\bsk-[A-Za-z0-9_-]{20,}')), + ('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 ("\t"), $2 groups JSON from +# compute_paths_to_stage, $3 `git cat-file --batch` output for every group +# member's index entry, $4 the staged paths. +# A flagged path is held with the rest of its staged group (its dependents). +# A group that is not held must stage whole and pass its generation check, or +# all its staged members wait. Writes the held entries (JSON) to $5, the +# paths to unstage to $6 and the waiting paths to $7. +plan_holds() { + python3 - "$@" "$SELF_BIN" <<'PYEOF' +import json, sys +hits_path, groups_path, blobs_path, staged_path, held_out, unstage_out, wait_out, self_bin = sys.argv[1:9] + +def lines(path): + with open(path, encoding="utf-8", errors="surrogateescape") as f: + return [l.rstrip("\r\n") for l in f if l.strip()] + +hits = [tuple(l.split("\t", 1)) for l in lines(hits_path) if "\t" in l] +with open(groups_path) as f: + groups = json.load(f) +staged = set(lines(staged_path)) +with open(blobs_path, "rb") as f: + data = f.read() + +# Index blobs in batch order (None for an entry the index does not have). +blobs, pos = {}, 0 +for g in groups: + for m in g["members"]: + nl = data.index(b"\n", pos) + header = data[pos:nl].split(b" ") + pos = nl + 1 + if header[-1] == b"missing": + blobs[m] = None + continue + size = int(header[2]) + blobs[m] = data[pos:pos + size] + pos += size + 1 + +def decisions_active(members): + log, snap = blobs.get(members[0]), blobs.get(members[1]) + events = [] + for line in (log or b"").decode("utf-8", "replace").split("\n"): + try: + e = json.loads(line) + except ValueError: + continue + if isinstance(e, dict): + events.append(e) + retired = {e.get("supersedes") for e in events if e.get("kind") in ("supersede", "redact")} + active = {e.get("id") for e in events if e.get("kind") == "decide" and e.get("id") not in retired} + if snap is None: + return not active + try: + rows = json.loads(snap.decode("utf-8", "replace")) + except ValueError: + return False + return isinstance(rows, list) and all(isinstance(r, dict) for r in rows) \ + and {r.get("id") for r in rows} == active + +CHECKS = {"decisions-active": decisions_active} + +hit_paths = {p for _, p in hits} +held, unstage, waiting, held_groups = [], [], [], set() +for rule, path in hits: + group = next((g for g in groups if path in g["members"]), None) + deps = [m for m in (group["staged"] if group else []) if m != path and m not in hit_paths and m in staged] + if group: + held_groups.add(group["members"][0]) + also = " and the files coupled with it" if deps else "" + held.append({ + "path": path, + "rule": rule, + "dependents": deps, + "fixes": [ + f"edit {path} so the secret scan no longer matches ({rule}); the next sync re-scans it", + f"{self_bin} --skip-file {path} (permanently excludes it{also} from future syncs; undo with {self_bin} --unskip-file {path})", + ], + }) + for m in [path] + deps: + if m not in unstage: + unstage.append(m) +for g in groups: + if g["members"][0] in held_groups: + continue + members_staged = [m for m in g["staged"] if m in staged] + if not members_staged: + continue + check = CHECKS.get(g.get("check")) + if len(members_staged) == len(g["staged"]) and check and check(g["members"]): + continue + for m in members_staged: + waiting.append(m) + if m not in unstage: + unstage.append(m) + +with open(held_out, "w") as f: + json.dump(held, f) +for path, rows in ((unstage_out, unstage), (wait_out, waiting)): + with open(path, "w", encoding="utf-8", errors="surrogateescape", newline="\n") as f: + f.writelines(r + "\n" for r in rows) +PYEOF +} + +# Fail closed: unstage everything, keep the queue, status blocked. +block_drain() { + git -C "$GSTACK_HOME" reset -q HEAD -- . >/dev/null 2>&1 || true + ST_DRAINABLE=0 + write_status "blocked" "$1; nothing was committed and the queue is kept. Edit or skip the flagged files ($SELF_BIN --skip-file ), or clear the batch with $SELF_BIN --drop-queue --yes" + echo "BRAIN_SYNC: blocked: $1" >&2 +} + +# Secret scan and group check for the staged paths in $1 (groups JSON in $2): +# one scanner pass attributes findings to staged paths, plan_holds decides +# what is held or waits, those paths are unstaged and leave $1 (their records +# stay queued), then the whole remaining staged diff must re-scan clean. +# Sets ST_HELD and WAITING. Returns 1 after falling back to block_drain. +hold_flagged_paths() { + local paths_file="$1" groups_file="$2" scan="$1.scan" rc=0 gate_rc=0 gate_out + WAITING=0 + : > "$scan.hits" + : > "$scan.blobs" + git -C "$GSTACK_HOME" diff --cached --no-renames > "$scan.diff" 2>/dev/null || rc=1 + git -C "$GSTACK_HOME" diff --cached --no-renames -z --name-only > "$scan.names" 2>/dev/null || rc=1 + [ "$rc" -eq 0 ] && { secret_scan attribute "$scan.diff" "$scan.names" "$scan.hits" || rc=1; } + if [ "$rc" -eq 0 ] && [ -s "$groups_file.batch" ]; then + tr -d '\r' < "$groups_file.batch" | git -C "$GSTACK_HOME" cat-file --batch > "$scan.blobs" 2>/dev/null || rc=1 + fi + [ "$rc" -eq 0 ] && { plan_holds "$scan.hits" "$groups_file" "$scan.blobs" "$paths_file" "$scan.held" "$scan.unstage" "$scan.wait" || rc=1; } + if [ "$rc" -ne 0 ]; then + block_drain "the secret scan could not tell which staged files it flagged" + return 1 + fi + ST_HELD=$(cat "$scan.held") + WAITING=$(grep -c . "$scan.wait" 2>/dev/null || true) + WAITING=${WAITING:-0} + if [ -s "$scan.unstage" ]; then + if ! tr -d '\r' < "$scan.unstage" | git --literal-pathspecs -C "$GSTACK_HOME" reset -q HEAD --pathspec-from-file=- >/dev/null 2>&1; then + block_drain "git could not unstage the held files" + return 1 + fi + { grep -vxF -f "$scan.unstage" "$paths_file" || true; } > "$paths_file.kept" + mv -f "$paths_file.kept" "$paths_file" + fi + if ! git -C "$GSTACK_HOME" diff --cached --no-renames > "$scan.diff" 2>/dev/null; then + block_drain "git could not read the staged diff for the re-scan" + return 1 + fi + gate_out=$(secret_scan gate "$scan.diff") || gate_rc=$? + [ "$gate_rc" -eq 0 ] && return 0 + if [ "$gate_rc" -eq 3 ]; then + block_drain "a re-scan after holding back the flagged files still matched ($gate_out)" + else + block_drain "the secret scan failed (exit $gate_rc)" + fi + return 1 +} + 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 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 " >&2 + echo "Usage: $SELF_BIN --skip-file " >&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 " >&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 diff --git a/test/brain-sync.test.ts b/test/brain-sync.test.ts index 64c67dce1..0e66c7455 100644 --- a/test/brain-sync.test.ts +++ b/test/brain-sync.test.ts @@ -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 = {}) => 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; 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 // --------------------------------------------------------------- From b0ece62e2d44c975e8950194f5beea3c946de11d Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 7 Oct 2026 20:10:04 +0000 Subject: [PATCH 3/3] feat(skill-start): ARTIFACTS_SYNC attention lines for a stuck sync Skill start now prints one fixed attention line per sync-status-table row that applies (held, blocked, push_failed, error, unknown, stale-push, stale-drain), each naming gstack-brain-sync by absolute path. Values from the status file are clamped (status code to the table, counts to digits, timestamps to their shape), so the status message, file paths and scanner output never reach the line. The preamble tells the model to surface the line in one sentence and continue. Skill-start tests are generated from the table in bin/gstack-brain-sync, and docs/gbrain-sync-errors.md carries the same table. Co-authored-by: v639dragoon Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- SKILL.md | 15 +- autoplan/SKILL.md | 15 +- benchmark-models/SKILL.md | 15 +- benchmark/SKILL.md | 15 +- bin/gstack-skill-start | 3 + browse/SKILL.md | 15 +- canary/SKILL.md | 15 +- codex/SKILL.md | 15 +- context-restore/SKILL.md | 15 +- context-save/SKILL.md | 15 +- design-consultation/SKILL.md | 15 +- design-html/SKILL.md | 15 +- design-review/SKILL.md | 15 +- design-shotgun/SKILL.md | 15 +- devex-review/SKILL.md | 15 +- diagram/SKILL.md | 15 +- docs/gbrain-sync-errors.md | 60 +++++-- docs/gbrain-sync.md | 30 ++-- document-generate/SKILL.md | 15 +- document-release/SKILL.md | 15 +- health/SKILL.md | 15 +- investigate/SKILL.md | 15 +- ios-clean/SKILL.md | 15 +- ios-design-review/SKILL.md | 15 +- ios-fix/SKILL.md | 15 +- ios-qa/SKILL.md | 15 +- ios-sync/SKILL.md | 15 +- land-and-deploy/SKILL.md | 15 +- landing-report/SKILL.md | 15 +- learn/SKILL.md | 15 +- make-pdf/SKILL.md | 15 +- office-hours/SKILL.md | 15 +- open-gstack-browser/SKILL.md | 15 +- pair-agent/SKILL.md | 15 +- plan-ceo-review/SKILL.md | 15 +- plan-design-review/SKILL.md | 15 +- plan-devex-review/SKILL.md | 15 +- plan-eng-review/SKILL.md | 15 +- plan-tune/SKILL.md | 15 +- qa-only/SKILL.md | 15 +- qa/SKILL.md | 15 +- retro/SKILL.md | 15 +- review/SKILL.md | 15 +- scrape/SKILL.md | 15 +- .../preamble/generate-brain-sync-block.ts | 15 +- setup-browser-cookies/SKILL.md | 15 +- setup-deploy/SKILL.md | 15 +- setup-gbrain/SKILL.md | 15 +- ship/SKILL.md | 15 +- skillify/SKILL.md | 15 +- spec/SKILL.md | 15 +- sync-gbrain/SKILL.md | 15 +- test-audit/SKILL.md | 15 +- test/fixtures/golden/claude-ship-SKILL.md | 15 +- test/fixtures/golden/codex-ship-SKILL.md | 15 +- test/fixtures/golden/factory-ship-SKILL.md | 15 +- test/gstack-skill-start.test.ts | 167 ++++++++++++++++++ 57 files changed, 654 insertions(+), 401 deletions(-) diff --git a/SKILL.md b/SKILL.md index 36313bbaf..eedcd0771 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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) diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index 00ea5148a..9e654d46e 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -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) diff --git a/benchmark-models/SKILL.md b/benchmark-models/SKILL.md index e9e6ad378..9dedb187b 100644 --- a/benchmark-models/SKILL.md +++ b/benchmark-models/SKILL.md @@ -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) diff --git a/benchmark/SKILL.md b/benchmark/SKILL.md index e0c575bc7..7c2549327 100644 --- a/benchmark/SKILL.md +++ b/benchmark/SKILL.md @@ -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) diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 6a922158f..6dbaaa698 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -358,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 diff --git a/browse/SKILL.md b/browse/SKILL.md index 740c287c7..ae139d535 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -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) diff --git a/canary/SKILL.md b/canary/SKILL.md index 25974f309..52b29161a 100644 --- a/canary/SKILL.md +++ b/canary/SKILL.md @@ -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) diff --git a/codex/SKILL.md b/codex/SKILL.md index 330c87a0e..82614174a 100644 --- a/codex/SKILL.md +++ b/codex/SKILL.md @@ -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) diff --git a/context-restore/SKILL.md b/context-restore/SKILL.md index 6df0d3965..fa5a03bc0 100644 --- a/context-restore/SKILL.md +++ b/context-restore/SKILL.md @@ -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) diff --git a/context-save/SKILL.md b/context-save/SKILL.md index db781f7c4..561a958db 100644 --- a/context-save/SKILL.md +++ b/context-save/SKILL.md @@ -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) diff --git a/design-consultation/SKILL.md b/design-consultation/SKILL.md index b2f2c3418..9e8b41a6d 100644 --- a/design-consultation/SKILL.md +++ b/design-consultation/SKILL.md @@ -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) diff --git a/design-html/SKILL.md b/design-html/SKILL.md index 0d7b2f9e3..fb4369040 100644 --- a/design-html/SKILL.md +++ b/design-html/SKILL.md @@ -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) diff --git a/design-review/SKILL.md b/design-review/SKILL.md index 1530bd59c..6ab4db87f 100644 --- a/design-review/SKILL.md +++ b/design-review/SKILL.md @@ -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) diff --git a/design-shotgun/SKILL.md b/design-shotgun/SKILL.md index 4bdd21bfe..e2aac6c4e 100644 --- a/design-shotgun/SKILL.md +++ b/design-shotgun/SKILL.md @@ -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) diff --git a/devex-review/SKILL.md b/devex-review/SKILL.md index ff12938d8..b6c56a120 100644 --- a/devex-review/SKILL.md +++ b/devex-review/SKILL.md @@ -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) diff --git a/diagram/SKILL.md b/diagram/SKILL.md index e6ec92011..74c64cdd1 100644 --- a/diagram/SKILL.md +++ b/diagram/SKILL.md @@ -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) diff --git a/docs/gbrain-sync-errors.md b/docs/gbrain-sync-errors.md index 001c72dc4..8c4152326 100644 --- a/docs/gbrain-sync-errors.md +++ b/docs/gbrain-sync-errors.md @@ -29,33 +29,57 @@ gstack-config set artifacts_sync_mode_prompted true --- -## `BRAIN_SYNC: blocked: :` +## `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=. The secret scan is holding back file(s); everything else still syncs. See which files and how to fix them: --status` | Edit the file so the scan no longer matches, or ` --skip-file ` (permanent; undo with `--unskip-file `) | +| `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: --status` | Edit or skip the files `--status` names, or clear the batch with ` --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: --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: --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: --status` | Run ` --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: --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: --status` | If no gstack process is running, remove `~/.gstack/.brain-sync.lock.d`, then run ` --once` | -**Fix (three options).** +`` 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 + ~/.claude/skills/gstack/bin/gstack-brain-sync --skip-file ``` - 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 ``` - 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. --- diff --git a/docs/gbrain-sync.md b/docs/gbrain-sync.md index 330ed29e2..6e308d89e 100644 --- a/docs/gbrain-sync.md +++ b/docs/gbrain-sync.md @@ -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: : +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: --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 ` 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 ` to permanently exclude that path; + `gstack-brain-sync --unskip-file ` 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. diff --git a/document-generate/SKILL.md b/document-generate/SKILL.md index b4a274ead..087e1b490 100644 --- a/document-generate/SKILL.md +++ b/document-generate/SKILL.md @@ -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) diff --git a/document-release/SKILL.md b/document-release/SKILL.md index 17d3a158e..a5c37f71f 100644 --- a/document-release/SKILL.md +++ b/document-release/SKILL.md @@ -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) diff --git a/health/SKILL.md b/health/SKILL.md index 322a967c7..e45520ca2 100644 --- a/health/SKILL.md +++ b/health/SKILL.md @@ -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) diff --git a/investigate/SKILL.md b/investigate/SKILL.md index 930b98366..45d2a2083 100644 --- a/investigate/SKILL.md +++ b/investigate/SKILL.md @@ -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) diff --git a/ios-clean/SKILL.md b/ios-clean/SKILL.md index 3bb422330..82d52986c 100644 --- a/ios-clean/SKILL.md +++ b/ios-clean/SKILL.md @@ -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) diff --git a/ios-design-review/SKILL.md b/ios-design-review/SKILL.md index 6b6e22a04..384c50781 100644 --- a/ios-design-review/SKILL.md +++ b/ios-design-review/SKILL.md @@ -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) diff --git a/ios-fix/SKILL.md b/ios-fix/SKILL.md index cbf765e09..ea959a160 100644 --- a/ios-fix/SKILL.md +++ b/ios-fix/SKILL.md @@ -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) diff --git a/ios-qa/SKILL.md b/ios-qa/SKILL.md index 100599489..18bbc70fc 100644 --- a/ios-qa/SKILL.md +++ b/ios-qa/SKILL.md @@ -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) diff --git a/ios-sync/SKILL.md b/ios-sync/SKILL.md index cf44a470f..e8cbddb8d 100644 --- a/ios-sync/SKILL.md +++ b/ios-sync/SKILL.md @@ -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) diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md index 324e9119a..33de0b5eb 100644 --- a/land-and-deploy/SKILL.md +++ b/land-and-deploy/SKILL.md @@ -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) diff --git a/landing-report/SKILL.md b/landing-report/SKILL.md index 9deba7b91..ea0715ecf 100644 --- a/landing-report/SKILL.md +++ b/landing-report/SKILL.md @@ -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) diff --git a/learn/SKILL.md b/learn/SKILL.md index 996955a14..f8aa1a821 100644 --- a/learn/SKILL.md +++ b/learn/SKILL.md @@ -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) diff --git a/make-pdf/SKILL.md b/make-pdf/SKILL.md index 8c04f7df8..a7984fea1 100644 --- a/make-pdf/SKILL.md +++ b/make-pdf/SKILL.md @@ -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) diff --git a/office-hours/SKILL.md b/office-hours/SKILL.md index ad39c5541..f326b8f17 100644 --- a/office-hours/SKILL.md +++ b/office-hours/SKILL.md @@ -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) diff --git a/open-gstack-browser/SKILL.md b/open-gstack-browser/SKILL.md index 2754d9db5..aa4071bb7 100644 --- a/open-gstack-browser/SKILL.md +++ b/open-gstack-browser/SKILL.md @@ -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) diff --git a/pair-agent/SKILL.md b/pair-agent/SKILL.md index 1d4b9302b..f7086a8e4 100644 --- a/pair-agent/SKILL.md +++ b/pair-agent/SKILL.md @@ -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) diff --git a/plan-ceo-review/SKILL.md b/plan-ceo-review/SKILL.md index 5fff78478..0131f5e41 100644 --- a/plan-ceo-review/SKILL.md +++ b/plan-ceo-review/SKILL.md @@ -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) diff --git a/plan-design-review/SKILL.md b/plan-design-review/SKILL.md index deb5e1031..c8a49f274 100644 --- a/plan-design-review/SKILL.md +++ b/plan-design-review/SKILL.md @@ -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) diff --git a/plan-devex-review/SKILL.md b/plan-devex-review/SKILL.md index 5a20d9568..c0384e9f9 100644 --- a/plan-devex-review/SKILL.md +++ b/plan-devex-review/SKILL.md @@ -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) diff --git a/plan-eng-review/SKILL.md b/plan-eng-review/SKILL.md index ad41beac6..008519c98 100644 --- a/plan-eng-review/SKILL.md +++ b/plan-eng-review/SKILL.md @@ -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) diff --git a/plan-tune/SKILL.md b/plan-tune/SKILL.md index 674c537ad..4bc67d841 100644 --- a/plan-tune/SKILL.md +++ b/plan-tune/SKILL.md @@ -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) diff --git a/qa-only/SKILL.md b/qa-only/SKILL.md index a8801d33c..2e4396520 100644 --- a/qa-only/SKILL.md +++ b/qa-only/SKILL.md @@ -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) diff --git a/qa/SKILL.md b/qa/SKILL.md index 167508805..137b68cd6 100644 --- a/qa/SKILL.md +++ b/qa/SKILL.md @@ -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) diff --git a/retro/SKILL.md b/retro/SKILL.md index e7591bfbd..1e6514433 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -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) diff --git a/review/SKILL.md b/review/SKILL.md index 851508312..3e620cebe 100644 --- a/review/SKILL.md +++ b/review/SKILL.md @@ -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) diff --git a/scrape/SKILL.md b/scrape/SKILL.md index 6aeaea27f..b5ef731dd 100644 --- a/scrape/SKILL.md +++ b/scrape/SKILL.md @@ -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) diff --git a/scripts/resolvers/preamble/generate-brain-sync-block.ts b/scripts/resolvers/preamble/generate-brain-sync-block.ts index e5552b955..7731f378a 100644 --- a/scripts/resolvers/preamble/generate-brain-sync-block.ts +++ b/scripts/resolvers/preamble/generate-brain-sync-block.ts @@ -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.`; } diff --git a/setup-browser-cookies/SKILL.md b/setup-browser-cookies/SKILL.md index 23f193d0c..1814e7dda 100644 --- a/setup-browser-cookies/SKILL.md +++ b/setup-browser-cookies/SKILL.md @@ -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) diff --git a/setup-deploy/SKILL.md b/setup-deploy/SKILL.md index a1df1d1d3..7748750b1 100644 --- a/setup-deploy/SKILL.md +++ b/setup-deploy/SKILL.md @@ -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) diff --git a/setup-gbrain/SKILL.md b/setup-gbrain/SKILL.md index 5facd7fff..c1f145a8a 100644 --- a/setup-gbrain/SKILL.md +++ b/setup-gbrain/SKILL.md @@ -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) diff --git a/ship/SKILL.md b/ship/SKILL.md index 1ffde620c..b1505e910 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -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) diff --git a/skillify/SKILL.md b/skillify/SKILL.md index a9aaed03e..514a0b3ad 100644 --- a/skillify/SKILL.md +++ b/skillify/SKILL.md @@ -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) diff --git a/spec/SKILL.md b/spec/SKILL.md index 14667d269..dec672c64 100644 --- a/spec/SKILL.md +++ b/spec/SKILL.md @@ -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) diff --git a/sync-gbrain/SKILL.md b/sync-gbrain/SKILL.md index 53fed3475..845e8eb4c 100644 --- a/sync-gbrain/SKILL.md +++ b/sync-gbrain/SKILL.md @@ -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) diff --git a/test-audit/SKILL.md b/test-audit/SKILL.md index 352a14c76..13d7fb84d 100644 --- a/test-audit/SKILL.md +++ b/test-audit/SKILL.md @@ -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) diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 1ffde620c..b1505e910 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -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) diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 36b112e73..dfcc30550 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -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) diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index d9153af7a..00bf178bf 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -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) diff --git a/test/gstack-skill-start.test.ts b/test/gstack-skill-start.test.ts index 0d159a046..0cadd4bd8 100644 --- a/test/gstack-skill-start.test.ts +++ b/test/gstack-skill-start.test.ts @@ -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 | 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 = {}) => ({ + status: code, held_count: 0, drainable: 0, last_drain_at: NOW(), last_push_at: NOW(), ts: NOW(), held: [], message: 'm', ...extra, + }); + + const FIXTURES: Record Record; 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(' --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('', SYNC).replaceAll('', 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, ''); + 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;