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 119342e9b..adfe2cee7 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-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-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/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..6dbaaa698 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" @@ -352,6 +358,9 @@ elif [ -d "$_GH/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then [ -f "$_GH/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(head -1 "$_GH/.brain-last-push" 2>/dev/null | tr -cd 'A-Za-z0-9._:+-' || echo never) _BRAIN_LAST_PUSH=${_BRAIN_LAST_PUSH:-never} echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH" + # A stuck sync (scanner hold, failed push or commit, no push or no drain for + # 24 hours) prints fixed attention lines; a healthy sync prints none. + "$_BRAIN_SYNC_BIN" --attention 2>/dev/null | grep '^ARTIFACTS_SYNC: attention: ' || true else echo "ARTIFACTS_SYNC: off" fi diff --git a/browse/SKILL.md b/browse/SKILL.md index a435e12fe..94df6eefa 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 2e84a8b5b..d07ed6a5b 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 56166ebba..63408d19b 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 c8c933816..ed13c9710 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 0dc2f8589..b7581156f 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 36f85bc0e..be28dd08b 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 f12958f4a..25a214dad 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 55f73a3a0..9202aa041 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 62bddce95..a054f1698 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/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 // --------------------------------------------------------------- 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;