diff --git a/bin/gstack-brain-enqueue b/bin/gstack-brain-enqueue new file mode 100755 index 00000000..e37799d2 --- /dev/null +++ b/bin/gstack-brain-enqueue @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# gstack-brain-enqueue — atomically append a path to the GBrain sync queue. +# +# Usage: +# gstack-brain-enqueue +# +# Called by writer scripts (gstack-learnings-log, gstack-timeline-log, etc.) +# after their local write. Fire-and-forget; failures are silent (never blocks +# the writer). Queue is drained by `gstack-brain-sync --once` invoked from the +# preamble at skill START and END boundaries. +# +# No-op when: +# - gbrain_sync_mode is off (the default) +# - ~/.gstack/.git doesn't exist (feature not initialized) +# - matches a line in ~/.gstack/.brain-skip.txt +# +# Env: +# GSTACK_HOME — override ~/.gstack state directory (aligns with writers). +# Tests use GSTACK_HOME=/tmp/test-$$ for isolation. +# +# Concurrency: POSIX append is atomic up to PIPE_BUF (~4KB Linux, 512 BSD). +# Queue lines are ~200 bytes, safe under concurrent callers. + +# No `-e` — writer shims rely on this never failing loudly. +set -uo pipefail + +FILE="${1:-}" +[ -z "$FILE" ] && exit 0 + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +QUEUE="$GSTACK_HOME/.brain-queue.jsonl" +SKIP_FILE="$GSTACK_HOME/.brain-skip.txt" + +# Fast exits: no git repo, no sync. +[ ! -d "$GSTACK_HOME/.git" ] && exit 0 + +# Check sync mode. off → silent no-op. +SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)" +MODE=$("$SCRIPT_DIR/gstack-config" get gbrain_sync_mode 2>/dev/null || echo off) +[ "$MODE" = "off" ] && exit 0 + +# User-maintained skip list (for secret-scan false positives). +if [ -f "$SKIP_FILE" ]; then + if grep -Fxq "$FILE" "$SKIP_FILE" 2>/dev/null; then + exit 0 + fi +fi + +# JSON-escape the file path (backslash + quotes only; paths shouldn't have other specials). +ESC_FILE=$(printf '%s' "$FILE" | sed 's/\\/\\\\/g; s/"/\\"/g') +TS=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "") + +printf '{"file":"%s","ts":"%s"}\n' "$ESC_FILE" "$TS" >> "$QUEUE" 2>/dev/null + +exit 0 diff --git a/bin/gstack-config b/bin/gstack-config index d715aee4..967478b0 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -8,10 +8,11 @@ # gstack-config defaults — show just the defaults table # # Env overrides (for testing): -# GSTACK_STATE_DIR — override ~/.gstack state directory +# GSTACK_HOME — override ~/.gstack state directory (aligns with writer scripts) +# GSTACK_STATE_DIR — legacy alias for GSTACK_HOME (kept for backwards compat) set -euo pipefail -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" +STATE_DIR="${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}" CONFIG_FILE="$STATE_DIR/config.yaml" # Annotated header for new config files. Written once on first `set`. @@ -59,6 +60,19 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # Unknown values default to "default" with a warning. # # See docs/designs/PLAN_TUNING_V1.md for rationale. # +# ─── GBrain sync (v1.7+) ───────────────────────────────────────────── +# gbrain_sync_mode: off # off | artifacts-only | full +# # off — no sync (default) +# # artifacts-only — sync plans/designs/retros/learnings only +# # (skip behavioral data: question-log, +# # developer-profile, timeline) +# # full — sync everything allowlisted +# # Set by the first-run privacy stop-gate. See docs/gbrain-sync.md. +# +# gbrain_sync_mode_prompted: false +# # Set to true once the privacy gate has asked the user. +# # Flip back to false to be re-prompted. +# # ─── Advanced ──────────────────────────────────────────────────────── # codex_reviews: enabled # disabled = skip Codex adversarial reviews in /ship # gstack_contributor: false # true = file field reports when gstack misbehaves @@ -83,6 +97,8 @@ lookup_default() { gstack_contributor) echo "false" ;; skip_eng_review) echo "false" ;; cross_project_learnings) echo "" ;; # intentionally empty → unset triggers first-time prompt + gbrain_sync_mode) echo "off" ;; + gbrain_sync_mode_prompted) echo "false" ;; *) echo "" ;; esac } @@ -114,6 +130,10 @@ case "${1:-}" in echo "Warning: explain_level '$VALUE' not recognized. Valid values: default, terse. Using default." >&2 VALUE="default" fi + if [ "$KEY" = "gbrain_sync_mode" ] && [ "$VALUE" != "off" ] && [ "$VALUE" != "artifacts-only" ] && [ "$VALUE" != "full" ]; then + echo "Warning: gbrain_sync_mode '$VALUE' not recognized. Valid values: off, artifacts-only, full. Using off." >&2 + VALUE="off" + fi mkdir -p "$STATE_DIR" # Write annotated header on first creation if [ ! -f "$CONFIG_FILE" ]; then @@ -142,7 +162,8 @@ case "${1:-}" in echo "# ─── Active values (including defaults for unset keys) ───" for KEY in proactive routing_declined telemetry auto_upgrade update_check \ skill_prefix checkpoint_mode checkpoint_push codex_reviews \ - gstack_contributor skip_eng_review; do + gstack_contributor skip_eng_review gbrain_sync_mode \ + gbrain_sync_mode_prompted; do VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) SOURCE="default" if [ -n "$VALUE" ]; then @@ -157,7 +178,8 @@ case "${1:-}" in echo "# gstack-config defaults" for KEY in proactive routing_declined telemetry auto_upgrade update_check \ skill_prefix checkpoint_mode checkpoint_push codex_reviews \ - gstack_contributor skip_eng_review; do + gstack_contributor skip_eng_review gbrain_sync_mode \ + gbrain_sync_mode_prompted; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; diff --git a/bin/gstack-developer-profile b/bin/gstack-developer-profile index c4a3360c..3e8ed0bd 100755 --- a/bin/gstack-developer-profile +++ b/bin/gstack-developer-profile @@ -101,6 +101,10 @@ do_migrate() { mv "$TMPOUT" "$PROFILE_FILE" trap - EXIT + # gbrain-sync: enqueue the migrated file for cross-machine sync (no-op if off). + SCRIPT_DIR_E="$(cd "$(dirname "$0")" && pwd)" + "$SCRIPT_DIR_E/gstack-brain-enqueue" "developer-profile.json" 2>/dev/null & + # Archive the legacy file. local TS TS="$(date +%Y-%m-%d-%H%M%S)" diff --git a/bin/gstack-jsonl-merge b/bin/gstack-jsonl-merge new file mode 100755 index 00000000..2be0ea9d --- /dev/null +++ b/bin/gstack-jsonl-merge @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# gstack-jsonl-merge — git merge driver for append-only JSONL files. +# +# Usage (called by git, not by users): +# gstack-jsonl-merge +# +# Registered in local git config by bin/gstack-brain-init and +# bin/gstack-brain-restore: +# git config merge.jsonl-append.driver \ +# "$GSTACK_BIN/gstack-jsonl-merge %O %A %B" +# +# Behavior: +# Concatenate base + ours + theirs, dedup exact-duplicate lines, sort by +# ISO "ts" field when present, fall back to SHA-256 of the line for +# deterministic order. Write result to (the %A file per the git +# merge-driver contract). +# +# Two machines appending to the same JSONL file between pushes produces +# a same-line conflict at the file tail. This driver resolves it cleanly: +# both appends survive, ordered by wall-clock timestamp where available, +# content hash otherwise. +# +# Exit codes: +# 0 — merge succeeded, result written to +# 1 — error; git treats as conflict and stops the merge + +set -uo pipefail + +if [ "$#" -lt 3 ]; then + echo "gstack-jsonl-merge: expected 3 args (base ours theirs), got $#" >&2 + exit 1 +fi + +BASE="$1" +OURS="$2" +THEIRS="$3" + +TMP=$(mktemp /tmp/gstack-jsonl-merge.XXXXXX) || exit 1 +trap 'rm -f "$TMP" 2>/dev/null || true' EXIT + +python3 - "$BASE" "$OURS" "$THEIRS" > "$TMP" <<'PYEOF' +import sys, json, hashlib + +paths = sys.argv[1:4] # base, ours, theirs +seen = {} # line content -> sort_key + +for path in paths: + try: + with open(path, 'r', encoding='utf-8') as f: + for line in f: + line = line.rstrip('\n') + if not line: + continue + if line in seen: + continue + # Prefer ISO ts field for sort; fall back to SHA-256. + sort_key = None + try: + obj = json.loads(line) + ts = obj.get('ts') or obj.get('timestamp') + if isinstance(ts, str): + sort_key = (0, ts) + except (json.JSONDecodeError, ValueError, TypeError): + pass + if sort_key is None: + h = hashlib.sha256(line.encode('utf-8')).hexdigest() + sort_key = (1, h) + seen[line] = sort_key + except FileNotFoundError: + # Absent base / absent ours / absent theirs are all valid. + continue + except OSError: + # Permission / IO errors are fatal — caller sees non-zero exit. + sys.exit(1) + +# Timestamp-ordered entries first (group 0), then hash-ordered (group 1). +for line, _ in sorted(seen.items(), key=lambda item: item[1]): + print(line) +PYEOF + +_PYEXIT=$? +if [ "$_PYEXIT" != "0" ]; then + exit 1 +fi + +mv "$TMP" "$OURS" || exit 1 +trap - EXIT +exit 0 diff --git a/bin/gstack-learnings-log b/bin/gstack-learnings-log index 6c528d3a..5f53e190 100755 --- a/bin/gstack-learnings-log +++ b/bin/gstack-learnings-log @@ -84,3 +84,6 @@ if [ $? -ne 0 ] || [ -z "$VALIDATED" ]; then fi echo "$VALIDATED" >> "$GSTACK_HOME/projects/$SLUG/learnings.jsonl" + +# gbrain-sync: enqueue for cross-machine sync (no-op if sync is off). +"$SCRIPT_DIR/gstack-brain-enqueue" "projects/$SLUG/learnings.jsonl" 2>/dev/null & diff --git a/bin/gstack-question-log b/bin/gstack-question-log index 2aecb536..4344843e 100755 --- a/bin/gstack-question-log +++ b/bin/gstack-question-log @@ -165,3 +165,7 @@ if [ $VALIDATE_RC -ne 0 ] || [ -z "$VALIDATED" ]; then fi echo "$VALIDATED" >> "$GSTACK_HOME/projects/$SLUG/question-log.jsonl" + +# NOTE: question-log.jsonl is deliberately NOT enqueued for gbrain-sync. +# Per Codex v2 review, audit/derivation data stays local alongside the +# question-preferences.json it annotates. diff --git a/bin/gstack-review-log b/bin/gstack-review-log index 62c9e171..fba2ee7d 100755 --- a/bin/gstack-review-log +++ b/bin/gstack-review-log @@ -16,3 +16,6 @@ if ! printf '%s' "$INPUT" | bun -e "JSON.parse(await Bun.stdin.text())" 2>/dev/n fi echo "$INPUT" >> "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" + +# gbrain-sync: enqueue for cross-machine sync (no-op if sync is off). +"$SCRIPT_DIR/gstack-brain-enqueue" "projects/$SLUG/$BRANCH-reviews.jsonl" 2>/dev/null & diff --git a/bin/gstack-timeline-log b/bin/gstack-timeline-log index 0167a1d0..9429b476 100755 --- a/bin/gstack-timeline-log +++ b/bin/gstack-timeline-log @@ -2,7 +2,10 @@ # gstack-timeline-log — append a timeline event to the project timeline # Usage: gstack-timeline-log '{"skill":"review","event":"started","branch":"main"}' # -# Session timeline: local-only, never sent anywhere. +# Session timeline: local by default. If the user enables `gbrain_sync_mode` +# with the `full` (not `artifacts-only`) privacy tier — via the first-run +# stop-gate from `gstack-brain-init` or the preamble — timeline events are +# published to the user's private GBrain sync repo. See docs/gbrain-sync.md. # Required fields: skill, event (started|completed). # Optional: branch, outcome, duration_s, session, ts. # Validation failure → skip silently (non-blocking). @@ -32,3 +35,6 @@ if ! printf '%s' "$INPUT" | bun -e "const j=JSON.parse(await Bun.stdin.text()); fi echo "$INPUT" >> "$GSTACK_HOME/projects/$SLUG/timeline.jsonl" + +# gbrain-sync: enqueue for cross-machine sync (no-op if sync is off). +"$SCRIPT_DIR/gstack-brain-enqueue" "projects/$SLUG/timeline.jsonl" 2>/dev/null &