Merge origin/main (v1.67.0.0) — reconcile convergent iOS Release-guard fixes

main's v1.67.0.0 independently landed the DebugBridgeTouch Release compile-out
with a stronger shape (`#if !defined(DEBUG)` short-circuit before the platform
gate, measured via nm -j on a real Release binary) than this branch's
`#if TARGET_OS_IOS && DEBUG`. Resolution: take main's templates/fixtures, keep
this branch's free-tier static tripwire and adapt it to pin main's shape
(short-circuit present, ordered before the platform branch, cSettings DEBUG
define intact, no bare platform-only gate). VERSION/package.json stay 1.67.1.0;
CHANGELOG keeps both entries with 1.67.1.0 on top, its iOS claims reworded to
the residual contribution (the tripwire, not the compile-out itself).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 20:18:43 -07:00
co-authored by Claude Fable 5
246 changed files with 13732 additions and 2289 deletions
+11
View File
@@ -291,6 +291,14 @@ projects/*/*-design-*.md
projects/*/*-test-plan-*.md
projects/*/*-eng-review-test-plan-*.md
projects/*/timeline.jsonl
# The decision store. gstack-decision-log enqueues projects/<slug>/decisions.jsonl
# after EVERY write, but no glob above matched it, so compute_paths_to_stage rejected
# all of them at its "must match at least one allowlist glob" check -- a writer
# enqueueing a path the syncer is guaranteed to drop. Without these the durable
# decision ledger never leaves the machine, on any platform.
projects/*/decisions.jsonl
projects/*/decisions.active.json
projects/*/decisions.archive.jsonl
retros/*.md
developer-profile.json
builder-journey.md
@@ -318,6 +326,9 @@ cat > "$GSTACK_HOME/.brain-privacy-map.json" <<'EOF'
{"pattern": "projects/*/*-design-*.md", "class": "artifact"},
{"pattern": "projects/*/*-test-plan-*.md", "class": "artifact"},
{"pattern": "projects/*/*-eng-review-test-plan-*.md", "class": "artifact"},
{"pattern": "projects/*/decisions.jsonl", "class": "artifact"},
{"pattern": "projects/*/decisions.active.json", "class": "artifact"},
{"pattern": "projects/*/decisions.archive.jsonl", "class": "artifact"},
{"pattern": "retros/*.md", "class": "artifact"},
{"pattern": "builder-journey.md", "class": "artifact"},
{"pattern": "projects/*/timeline.jsonl", "class": "behavioral"},
+77 -4
View File
@@ -126,13 +126,27 @@ function sha8(input: string): string {
* Detects the active brain endpoint (MCP URL or 'local') and returns its
* stable identity hash. Used to detect when the user switches brains
* (different endpoint → different cache).
*
* Reads BOTH registration scopes in ~/.claude.json (#2499): user scope
* (.mcpServers.gbrain) first, then project scope
* (.projects["/abs/path"].mcpServers.gbrain — what `claude mcp add`
* WITHOUT --scope user writes), preferring the nearest ancestor of cwd
* (longest matching project key) so nested repos resolve to their own
* brain. Before the project-scope read, two different project-scoped
* brains both hashed to 'local', so switching between them never
* invalidated the cache — the exact scenario this function exists to
* catch.
*
* Params exist for tests; production callers use the defaults.
*/
export function detectEndpointHash(): string {
const claudeJsonPath = join(homedir(), '.claude.json');
export function detectEndpointHash(
claudeJsonPath: string = join(homedir(), '.claude.json'),
cwd: string = process.cwd(),
): string {
if (existsSync(claudeJsonPath)) {
try {
const cfg = JSON.parse(readFileSync(claudeJsonPath, 'utf-8'));
const gbrainServer = cfg?.mcpServers?.gbrain;
const gbrainServer = resolveGbrainMcpEntry(cfg, cwd);
const url = gbrainServer?.url || gbrainServer?.transport?.url;
if (typeof url === 'string' && url.length > 0) {
return sha8(url);
@@ -143,6 +157,40 @@ export function detectEndpointHash(): string {
return 'local';
}
interface McpEntryish {
url?: unknown;
transport?: { url?: unknown };
}
/**
* User-scope gbrain entry, else the nearest-ancestor project-scope entry
* for cwd (#2499). Path-boundary-aware: /a/repo never matches /a/repo2.
* Both separators are accepted so Windows project keys resolve.
*/
function resolveGbrainMcpEntry(
cfg: unknown,
cwd: string,
): McpEntryish | undefined {
const root = cfg as {
mcpServers?: Record<string, McpEntryish>;
projects?: Record<string, { mcpServers?: Record<string, McpEntryish> }>;
} | null;
if (root?.mcpServers?.gbrain) return root.mcpServers.gbrain;
const projects = root?.projects;
if (!projects || typeof projects !== 'object') return undefined;
let best: { key: string; entry: McpEntryish } | undefined;
for (const [key, val] of Object.entries(projects)) {
if (!val || typeof val !== 'object') continue;
const entry = val.mcpServers?.gbrain;
if (!entry || typeof entry !== 'object') continue;
const isAncestor =
cwd === key || cwd.startsWith(`${key}/`) || cwd.startsWith(`${key}\\`);
if (!isAncestor) continue;
if (!best || key.length > best.key.length) best = { key, entry };
}
return best?.entry;
}
// ──────────────────────────────────────────────────────────────────────────
// Atomic write (tmp + rename)
// ──────────────────────────────────────────────────────────────────────────
@@ -521,6 +569,21 @@ function fetchRecentDecisions(projectSlug: string | null): string | null {
'--json',
]);
if (!result?.pages) {
// F10 bug fix: this branch used to return the hardcoded
// "_No prior skill runs recorded._" string here, which is indistinguishable
// from a genuine zero-rows result. That silently converted a gbrain-
// unreachable FAILURE into a "successful" cached digest — refreshEntity()
// would write it and stamp last_refresh, so the false negative survived
// every subsequent TTL cycle forever. Returning null instead lets cmdGet's
// existing missing/stale-fallback machinery report the true state, exactly
// like every sibling fetcher (fetchGoals, fetchSimplePage) already does on
// failure.
return null;
}
// A malformed payload ({pages: {}} etc.) must classify as failure, not crash
// refreshEntity mid-refresh — same honest-missing polarity as the F10 fix.
if (!Array.isArray(result.pages)) return null;
if (result.pages.length === 0) {
return `# Recent decisions (project: ${projectSlug})\n\n_No prior skill runs recorded._\n`;
}
const lines = result.pages.map((p) => `- ${p.title || p.slug}`);
@@ -576,7 +639,17 @@ function fetchSalience(projectSlug: string | null): string | null {
'--limit', '10',
'--json',
]);
if (!result?.pages) return `# Recent salience\n\n_No salient pages in last 14d._\n`;
// F10 bug fix (sibling of fetchRecentDecisions above): a gbrain-unreachable
// failure used to render the identical hardcoded "no salient pages" string
// as a genuine empty result, which refreshEntity() then cached as if it
// were verified truth. Unlike recent-decisions there is no project-local
// fallback for salience — it is specifically gbrain's emotional-weight-
// ranked *brain* pages, not project decision/work data, and conflating the
// two would defeat the D9 privacy allowlist's purpose. So on failure we
// return null and let the cache report 'missing' (same as product.md,
// goals.md, etc. already do on this machine) instead of asserting a claim
// we have no way to verify.
if (!result?.pages) return null;
// D9 privacy gate: strip entries outside the allowlist BEFORE rendering.
// Sensitive personal content (family, therapy, reflection) is never written
+212 -21
View File
@@ -122,12 +122,23 @@ sys.exit(0)
# Compute matched allowlisted, privacy-filtered path set from queue.
# Output: newline-delimited relative paths that should be staged.
#
# #2549: every non-staged queue entry is CLASSIFIED, never silently discarded.
# When $2 is given, a JSON classification lands there:
# {"retained": [privacy/mode-held paths that stay queued],
# "dropped": {"skipped": [...], "invalid": [...], "unmatched": [...], "missing": [...]}}
# retained entries would sync if the user raises artifacts_sync_mode, so they
# stay in the queue; dropped classes can never sync (explicit skip, escape
# attempt, no allowlist glob, not on disk) and are removed WITH a counted
# status — the old behavior truncated the whole queue and reported every one
# of these, including privacy holds, as "no allowlisted changes".
compute_paths_to_stage() {
local mode="$1"
python3 - "$GSTACK_HOME" "$QUEUE" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" <<'PYEOF'
local class_file="${2:-}"
python3 - "$GSTACK_HOME" "$QUEUE" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" "$class_file" <<'PYEOF'
import sys, json, os, fnmatch, glob
gstack_home, queue, allowlist_path, privacy_path, skip_path, mode = sys.argv[1:7]
gstack_home, queue, allowlist_path, privacy_path, skip_path, mode, class_file = sys.argv[1:8]
def load_lines(path):
try:
@@ -195,29 +206,135 @@ def mode_allows(cls, mode):
return True # full
final = []
classified = {"retained": [], "dropped": {"skipped": [], "invalid": [], "unmatched": [], "missing": []}}
for p in sorted(queue_paths):
if p in skip_lines:
classified["dropped"]["skipped"].append(p)
continue
# Must be under GSTACK_HOME root. Reject absolute + reject ../ escape.
if p.startswith("/") or ".." in p.split("/"):
classified["dropped"]["invalid"].append(p)
continue
# Must match at least one allowlist glob.
if not path_matches_any(p, allowlist_globs):
classified["dropped"]["unmatched"].append(p)
continue
# Must survive privacy mode filter.
# Must survive privacy mode filter — held entries STAY QUEUED (retained):
# they would sync under a higher artifacts_sync_mode, and reporting them
# as "no allowlisted changes" was #2549's misattribution.
cls = privacy_class(p, privacy_map)
if not mode_allows(cls, mode):
classified["retained"].append(p)
continue
# Must exist on disk — can't stage what isn't there.
if not os.path.exists(os.path.join(gstack_home, p)):
classified["dropped"]["missing"].append(p)
continue
final.append(p)
if class_file:
with open(class_file, "w") as f:
json.dump(classified, f)
for p in final:
print(p)
PYEOF
}
# #2549: surgical queue rewrite — replaces every whole-queue truncation
# (`: > "$QUEUE"`). Re-reads the LIVE queue at rewrite time (a writer may have
# enqueued while we were staging/pushing — those entries must survive; the old
# truncation destroyed them) and keeps every line whose file is either
# retained (privacy/mode-held) or not part of this drain at all. Atomic
# tmp+mv in the same directory. Dropped-path detail goes to a 0600 sidecar so
# the status line can stay content-free (counts only).
rewrite_queue() {
local paths_file="$1" # staged (drained) paths, one per line
local class_file="$2" # classification JSON from compute_paths_to_stage
# Fail-open by design (a failed rewrite self-corrects next run: re-stage →
# nothing-to-commit), but say so — a silent failure here would let the
# subsequent "ok/idle" status claim a drain that did not happen.
python3 - "$QUEUE" "$paths_file" "$class_file" "$GSTACK_HOME/.brain-sync-drops.json" <<'PYEOF' || echo "BRAIN_SYNC: warning: queue rewrite failed — entries retained; next run re-drains" >&2
import json, os, sys, time
queue, paths_file, class_file, drops_file = sys.argv[1:5]
def lines(path):
try:
with open(path) as f:
return [l.rstrip("\r\n") for l in f if l.strip()]
except FileNotFoundError:
return []
staged = set(lines(paths_file))
try:
with open(class_file) as f:
classified = json.load(f)
except Exception:
classified = {"retained": [], "dropped": {}}
retained = set(classified.get("retained", []))
dropped = set()
for group in (classified.get("dropped", {}) or {}).values():
dropped.update(group)
processed = staged | dropped
kept = []
seen_lines = set()
unparseable = 0
# LIVE re-read narrows (not fully closes) the concurrent-append window: the
# lockless enqueue can still land on the old inode between this read and the
# os.replace below. Vastly better than the old whole-queue truncation.
for line in lines(queue):
if line in seen_lines:
continue # identical duplicate lines collapse on rewrite
try:
p = json.loads(line).get("file")
except Exception:
unparseable += 1
kept.append(line) # unparseable line: keep, never destroy
seen_lines.add(line)
continue
if not isinstance(p, str) or p in retained or p not in processed:
kept.append(line)
seen_lines.add(line)
if unparseable:
import sys as _sys
print(f"BRAIN_SYNC: {unparseable} unparseable queue line(s) held (inspect {queue})", file=_sys.stderr)
tmp = queue + ".tmp." + str(os.getpid())
with open(tmp, "w") as f:
for l in kept:
f.write(l + "\n")
os.replace(tmp, queue)
if dropped:
fd = os.open(drops_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
json.dump({"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"dropped": classified.get("dropped", {})}, f)
PYEOF
}
# Human-readable classification counts for status messages.
queue_summary() {
local class_file="$1"
python3 - "$class_file" <<'PYEOF' 2>/dev/null || echo ""
import json, sys
try:
with open(sys.argv[1]) as f:
c = json.load(f)
except Exception:
print(""); sys.exit(0)
d = c.get("dropped", {}) or {}
parts = []
r = len(c.get("retained", []))
if r: parts.append(f"{r} privacy-held retained")
for k in ("skipped", "unmatched", "missing", "invalid"):
n = len(d.get(k, []))
if n: parts.append(f"{n} {k} dropped")
print("; ".join(parts))
PYEOF
}
subcmd_once() {
if ! sync_active; then
# Silent no-op when feature not initialized / disabled.
@@ -249,20 +366,90 @@ subcmd_once() {
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
local mode
mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off)
local paths_file
paths_file=$(mktemp /tmp/brain-sync-paths.XXXXXX) || { rm -rf "$lock_dir" 2>/dev/null; write_status "error" "mktemp failed"; exit 1; }
# Single trap covers both: lock cleanup AND tempfile cleanup.
trap 'rm -f "$paths_file" 2>/dev/null; rm -rf "$lock_dir" 2>/dev/null || true' EXIT INT TERM
# #2549 unpushed-commit detector: a prior drain may have COMMITTED but
# failed to push (auth blip, offline). The data was never lost — it sits in
# a local commit — but nothing re-pushed it until NEW changes arrived.
# Retry the push up front, inside the lock. Receipted fail-closed like
# every other push; a receipt REFUSAL skips the retry without blocking the
# rest of the drain (local staging must not wedge on receipt problems).
# Guards: origin/<branch> may not exist yet (first sync, deleted remote).
#
# Throttled: the preamble runs --once at EVERY skill boundary, so an
# unthrottled retry would pay a full network push attempt per boundary in
# exactly the steady states this targets (offline, broken auth) — and a
# captive-portal push can block 30-75s against the header's "<1s when
# idle" promise. Attempts are recorded (success or fail) and retried at
# most every 10 minutes; the push itself never prompts for credentials and
# bounds stalled transfers via git's own low-speed limits (portable — stock
# macOS ships no `timeout` binary).
#
# Author-scoped — EXCLUSIVELY: `git push origin HEAD` publishes every
# unpushed commit, so the retry fires only when ALL unpushed commits are
# gstack-brain-sync's own. One interleaved user commit disables the
# auto-retry entirely (adversarial review: an existential check would
# silently auto-publish a user's manual ~/.gstack commit the moment a bot
# commit sat in front of it). User commits ride along when a REAL drain
# pushes, as before — the detector never publishes work it didn't create.
local det_branch det_unpushed det_total det_now det_last
det_branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
# Detached HEAD reads as the literal "HEAD" — origin/HEAD usually resolves,
# so without this exclusion the detector would retry a doomed push forever.
[ "$det_branch" = "HEAD" ] && det_branch=""
if [ -n "$det_branch" ] && git -C "$GSTACK_HOME" rev-parse --verify --quiet "origin/$det_branch" >/dev/null 2>&1; then
det_unpushed=$(git -C "$GSTACK_HOME" rev-list --count --author="gstack-brain-sync" "origin/$det_branch..HEAD" 2>/dev/null || echo 0)
det_total=$(git -C "$GSTACK_HOME" rev-list --count "origin/$det_branch..HEAD" 2>/dev/null || echo 0)
case "$det_unpushed" in ''|*[!0-9]*) det_unpushed=0 ;; esac
case "$det_total" in ''|*[!0-9]*) det_total=0 ;; esac
det_now=$(date +%s)
det_last=$(cat "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || echo 0)
case "$det_last" in ''|*[!0-9]*) det_last=0 ;; esac
if [ "$det_unpushed" -gt 0 ] && [ "$det_unpushed" -eq "$det_total" ] && [ $(( det_now - det_last )) -ge 600 ]; then
echo "$det_now" > "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || true
local det_host
det_host=$(remote_host)
if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$det_host" curated-memory-git-push "artifacts_sync_mode!=off" \
bash -c 'GIT_TERMINAL_PROMPT=0 git -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=30 -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then
date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE"
fi
fi
fi
compute_paths_to_stage "$mode" > "$paths_file"
# Empty-queue fast path: this is the steady state at every skill boundary.
# Skipping compute/rewrite here is safe — with zero queue lines there is
# nothing to classify, retain, or drop, and a concurrent append after this
# check simply waits for the next boundary. (The detector above already ran:
# its whole point is re-pushing stranded commits when the queue is empty.)
# The lock-release trap installed at acquisition covers this exit.
if [ ! -s "$QUEUE" ]; then
write_status "idle" "queue empty"
exit 0
fi
local paths_file class_file
paths_file=$(mktemp /tmp/brain-sync-paths.XXXXXX) || { rm -rf "$lock_dir" 2>/dev/null; write_status "error" "mktemp failed"; exit 1; }
class_file=$(mktemp /tmp/brain-sync-class.XXXXXX) || { rm -f "$paths_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" 2>/dev/null; rm -rf "$lock_dir" 2>/dev/null || true' EXIT INT TERM
compute_paths_to_stage "$mode" "$class_file" > "$paths_file"
if [ ! -s "$paths_file" ]; then
# Nothing to stage. Clear any stale queue entries and exit.
: > "$QUEUE"
write_status "idle" "no allowlisted changes in queue"
# Nothing stageable. Rewrite the queue (retained entries + concurrent
# appends survive; classified drops removed) instead of truncating it.
rewrite_queue "$paths_file" "$class_file"
local summary
summary=$(queue_summary "$class_file")
write_status "idle" "no stageable changes${summary:+ ($summary)}"
exit 0
fi
@@ -309,8 +496,9 @@ subcmd_once() {
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).
: > "$QUEUE"
# Nothing to commit (e.g. all files already committed). The drained
# paths leave the queue; retained + concurrent entries survive (#2549).
rewrite_queue "$paths_file" "$class_file"
write_status "idle" "queue drained but no new changes to commit"
exit 0
}
@@ -322,10 +510,12 @@ subcmd_once() {
if echo "$push_err" | grep -qiE "auth|permission|403|401|forbidden"; then
local hint
hint=$(remote_auth_hint)
write_status "push_failed" "push failed: auth error. fix: $hint"
write_status "push_failed" "push failed: auth error; commit retained locally, will retry next run. fix: $hint"
echo "BRAIN_SYNC: push failed: auth. fix: $hint" >&2
# Queue cleared because the commit exists locally; next push will send it.
: > "$QUEUE"
# Drained paths leave the queue — they live in the local commit, which
# the run-start detector re-pushes next time (#2549). Retained +
# concurrent entries survive the rewrite.
rewrite_queue "$paths_file" "$class_file"
exit 0
fi
@@ -339,20 +529,21 @@ subcmd_once() {
if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1; then
if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \
bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then
: > "$QUEUE"
rewrite_queue "$paths_file" "$class_file"
date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE"
write_status "ok" "pushed $n file(s) after rebase"
exit 0
fi
fi
fi
write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1)"
: > "$QUEUE"
# Commit exists locally; the run-start detector re-pushes it next time.
write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1); commit retained locally, will retry next run"
rewrite_queue "$paths_file" "$class_file"
exit 0
}
# Success: clear queue, update last-push.
: > "$QUEUE"
# Success: drained paths leave the queue (retained + concurrent survive).
rewrite_queue "$paths_file" "$class_file"
date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE"
write_status "ok" "pushed $n file(s)"
exit 0
+113 -4
View File
@@ -4,6 +4,7 @@
#
# Functions (all prefixed with _gstack_codex_ for namespace hygiene):
# _gstack_codex_auth_probe — multi-signal auth check (env + file)
# _gstack_codex_model_probe — round-trip probe of the configured model (#2477)
# _gstack_codex_version_check — warn on known-bad Codex CLI versions
# _gstack_codex_timeout_wrapper — gtimeout -> timeout -> unwrapped fallback
# _gstack_codex_log_event — telemetry emission to ~/.gstack/analytics/
@@ -33,6 +34,92 @@ _gstack_codex_auth_probe() {
return 1
}
# --- Model round-trip probe (#2477) ------------------------------------------
_gstack_codex_model_probe() {
# Auth-exists is a weaker signal than the auth probe implies: a ChatGPT
# account with a stale `model = "..."` pin in ~/.codex/config.toml passes
# the auth probe, then EVERY invocation dies with an HTTP 400 ("The
# '<model>' model is not supported when using Codex with a ChatGPT
# account") and no guidance. A short real round trip with the configured
# model catches model rejection, entitlement changes, and stale pins in
# one shot (#2477).
#
# Contract:
# MODEL_OK (exit 0) — round trip succeeded; cached 1h.
# MODEL_UNUSABLE (exit 1) — deterministic model 400; hints printed.
# Cached 15 min: the 400 is config-driven, so re-probing every preflight
# charged the affected user a 30s round trip + real tokens per review
# section, forever. Editing config.toml (the fix) changes the cache
# signature and re-probes immediately; the short TTL covers server-side
# entitlement recovery the signature can't see.
# MODEL_PROBE_INCONCLUSIVE (exit 0) — timeout/transient; FAIL-OPEN so a
# slow network never wedges codex mode (the per-invocation Error
# Handling entry still covers a later 400). Never cached.
#
# Only call this AFTER _gstack_codex_auth_probe passes — probing without
# auth just measures the auth failure again.
local _codex_home="${CODEX_HOME:-$HOME/.codex}"
local _gstack_home="${GSTACK_HOME:-$HOME/.gstack}"
local _cache="$_gstack_home/.codex-model-probe"
# Cache signature: config.toml + auth.json mtimes. Editing the model pin
# or re-logging-in invalidates the cached MODEL_OK immediately.
# GNU-first stat order + numeric validation (the #2195 pattern): on GNU
# stat, `-f` means FILESYSTEM mode, so the BSD-first form emitted a
# multi-line filesystem block on Linux — the signature then never matched
# its own cache line and the cache missed on every read. BSD stat rejects
# `-c` cleanly, so GNU-first degrades correctly on macOS.
local _cfg_m _auth_m _sig
_cfg_m=$(stat -c %Y "$_codex_home/config.toml" 2>/dev/null || stat -f %m "$_codex_home/config.toml" 2>/dev/null || echo 0)
_auth_m=$(stat -c %Y "$_codex_home/auth.json" 2>/dev/null || stat -f %m "$_codex_home/auth.json" 2>/dev/null || echo 0)
case "$_cfg_m" in ''|*[!0-9]*) _cfg_m=0 ;; esac
case "$_auth_m" in ''|*[!0-9]*) _auth_m=0 ;; esac
_sig="${_cfg_m}-${_auth_m}"
local _now
_now=$(date +%s 2>/dev/null || echo 0)
if [ -f "$_cache" ]; then
local _c_line _c_status _c_ts _c_sig
_c_line=$(head -1 "$_cache" 2>/dev/null)
_c_status=$(printf '%s' "$_c_line" | cut -d' ' -f1)
_c_ts=$(printf '%s' "$_c_line" | cut -d' ' -f2)
_c_sig=$(printf '%s' "$_c_line" | cut -d' ' -f3)
case "$_c_ts" in ''|*[!0-9]*) _c_ts=0 ;; esac
if [ "$_c_status" = "MODEL_OK" ] && [ "$_c_sig" = "$_sig" ] && [ $((_now - _c_ts)) -lt 3600 ]; then
echo "MODEL_OK (cached)"
return 0
fi
if [ "$_c_status" = "MODEL_UNUSABLE" ] && [ "$_c_sig" = "$_sig" ] && [ $((_now - _c_ts)) -lt 900 ]; then
echo "MODEL_UNUSABLE (cached)"
echo "HINT: the rejected model comes from the 'model = ' line in $_codex_home/config.toml."
echo "HINT: check its [notice.model_migrations] table — Codex records the intended replacement there."
return 1
fi
fi
local _out _code
_out=$(_gstack_codex_timeout_wrapper 30 codex exec --skip-git-repo-check -s read-only "reply OK" </dev/null 2>&1)
_code=$?
if [ "$_code" -eq 0 ]; then
mkdir -p "$_gstack_home" 2>/dev/null || true
printf 'MODEL_OK %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null || true
echo "MODEL_OK"
return 0
fi
if printf '%s' "$_out" | grep -qiE 'model.{0,40}is not supported|"status":[[:space:]]*400'; then
mkdir -p "$_gstack_home" 2>/dev/null || true
printf 'MODEL_UNUSABLE %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null || true
echo "MODEL_UNUSABLE"
printf '%s\n' "$_out" | grep -i "model" | head -3
echo "HINT: the rejected model comes from the 'model = ' line in $_codex_home/config.toml."
echo "HINT: check its [notice.model_migrations] table — Codex records the intended replacement there."
_gstack_codex_log_event "codex_model_unusable" 2>/dev/null || true
return 1
fi
# Timeout (124) or transient failure: fail-open with a warning. The probe
# exists to catch the deterministic model 400, not to gate on network luck.
echo "MODEL_PROBE_INCONCLUSIVE (exit $_code) — proceeding; if invocations fail with a model 400, see the codex skill's Error Handling entry."
return 0
}
# --- Version check ----------------------------------------------------------
_gstack_codex_version_check() {
@@ -53,8 +140,8 @@ _gstack_codex_version_check() {
_gstack_codex_timeout_wrapper() {
# Resolve wrapper binary: prefer gtimeout (Homebrew coreutils on macOS),
# fall back to timeout (Linux), else run unwrapped. Arguments: $1 is the
# duration in seconds; rest is the command to run.
# fall back to timeout (Linux), else a bash-native watchdog. Arguments:
# $1 is the duration in seconds; rest is the command to run.
local _duration="$1"
shift
local _to
@@ -62,7 +149,29 @@ _gstack_codex_timeout_wrapper() {
if [ -n "$_to" ]; then
"$_to" "$_duration" "$@"
else
"$@"
# Stock macOS ships neither coreutils gtimeout nor timeout(1); running
# unwrapped let a hung `codex exec` block the probe — and the calling
# workflow — indefinitely. Emulate: background the command, TERM it at
# the deadline, mirror timeout(1)'s exit-124 contract. The watchdog's
# stdout is detached so an early finish never blocks a caller's $(...)
# capture on the orphaned sleep.
"$@" &
local _cmd_pid=$!
( sleep "$_duration" && kill -TERM "$_cmd_pid" 2>/dev/null ) >/dev/null 2>&1 &
local _watch_pid=$!
local _rc
wait "$_cmd_pid"
_rc=$?
if kill -0 "$_watch_pid" 2>/dev/null; then
# Command finished before the deadline. Retiring the watchdog subshell
# also defuses its pending kill (the `&& kill` lives in the subshell);
# its detached sleep expires harmlessly.
kill "$_watch_pid" 2>/dev/null
wait "$_watch_pid" 2>/dev/null
elif [ "$_rc" -ge 128 ]; then
_rc=124 # killed by the watchdog: report timeout(1)'s code
fi
return "$_rc"
fi
}
@@ -72,7 +181,7 @@ _gstack_codex_log_event() {
# Emit a telemetry event to ~/.gstack/analytics/skill-usage.jsonl.
# Gated on $_TEL != "off" (caller sets this from gstack-config).
# Event types: codex_timeout, codex_auth_failed, codex_cli_missing,
# codex_version_warning.
# codex_version_warning, codex_model_unusable.
# Payload schema: {skill, event, duration_s, ts}. NEVER includes prompt
# content, env var values, or auth tokens.
local _event="$1"
+53 -16
View File
@@ -17,6 +17,21 @@ set -euo pipefail
STATE_DIR="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}}"
CONFIG_FILE="$STATE_DIR/config.yaml"
# Swap a freshly-rendered tmp dir into the live render location (#2569
# hardening). Installed skills SYMLINK into the live dir, so it is only ever
# replaced AFTER a successful render — a failed render leaves the previous
# render (and every link into it) fully intact. Keep in sync with setup's
# _swap_in_render (same contract, both pinned by
# test/user-render-out-dir-install.test.ts).
_swap_in_render() {
local render_dir="$1" render_tmp="$2"
local render_old="$render_dir.old.$$"
rm -rf "$render_old"
if [ -e "$render_dir" ] || [ -L "$render_dir" ]; then mv "$render_dir" "$render_old"; fi
mv "$render_tmp" "$render_dir"
rm -rf "$render_old"
}
# Annotated header for new config files. Written once on first `set`.
# Default semantics: DEFAULTS table below is the canonical source. Header text
# is documentation that must stay in sync with DEFAULTS.
@@ -434,33 +449,55 @@ case "${1:-}" in
fi
case "$STATUS" in
ok|timeout|thin-client)
ok|timeout|thin-client|engine-locked)
# "timeout" = slow-but-healthy engine (#1964); "thin-client" =
# remote-HTTP MCP brain, no local engine by design (#2051) — same
# treatment as "ok", matching gstack-gbrain-detect --is-ok and
# gen-skill-docs.
# remote-HTTP MCP brain, no local engine by design (#2051);
# "engine-locked" = same class (#2456): PGLite is single-writer, so a
# live `gbrain serve` (typically an MCP server) holds the embedded DB.
# gbrain is installed and healthy; a transient lock must not strip
# brain blocks out of every SKILL.md. All get the same treatment as
# "ok", matching gstack-gbrain-detect --is-ok and gen-skill-docs.
echo "Detected gbrain v$VERSION (local-status: $STATUS)."
# Render brain-aware blocks INTO the global install so EVERY project's
# Claude sessions get them (other projects read SKILL.md + sections from
# ~/.claude/skills/gstack via absolute paths baked at gen time). Guards
# (never mutate an arbitrary directory): the target must exist, not be a
# symlink (a symlinked install points at a dev worktree — rendering there
# would dirty tracked source), and look like a real gstack clone.
# Render brain-aware blocks into an UNTRACKED out-dir (#2569) and
# repoint the installed skills at it — the old in-place render wrote
# into TRACKED files of the global install checkout, so the checkout
# stayed permanently dirty and every upgrade grew a redundant stash.
# Guards (never mutate an arbitrary directory): the install must
# exist, not be a symlink (a symlinked install points at a dev
# worktree — bin/dev-setup owns that flow), and look like a real
# gstack clone.
INSTALL_DIR="$HOME/.claude/skills/gstack"
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}"
if [ ! -d "$INSTALL_DIR" ]; then
echo "No global install at $INSTALL_DIR — nothing to render. (Dev workspaces get blocks via bin/dev-setup.)"
elif [ -L "$INSTALL_DIR" ]; then
echo "Skip: $INSTALL_DIR is a symlink (likely a dev worktree). Rendering there would dirty tracked source — run bin/dev-setup in that worktree instead."
echo "Skip: $INSTALL_DIR is a symlink (likely a dev worktree). Run bin/dev-setup in that worktree instead."
elif [ ! -f "$INSTALL_DIR/VERSION" ] || [ ! -f "$INSTALL_DIR/package.json" ]; then
echo "Skip: $INSTALL_DIR doesn't look like a gstack clone (missing VERSION/package.json) — refusing to modify it."
elif ! command -v bun >/dev/null 2>&1; then
echo "Skip: bun not on PATH — can't render. Install bun, then re-run 'gstack-config gbrain-refresh'."
elif ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude >/dev/null 2>&1 ); then
echo "Rendered brain-aware blocks into $INSTALL_DIR — now live across all your projects' Claude sessions."
echo "Note: this dirties the install's git tree (generated blocks differ from main, by design)."
echo " A 'git reset --hard origin/main' there reverts them; re-run 'gstack-config gbrain-refresh' to restore."
else
echo "Warning: render failed. Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude' manually to see the error."
# Render into a tmp dir and swap it in only on SUCCESS. Installed
# skills SYMLINK into $RENDER_DIR (gstack-relink prefers it), so
# wiping it before the render meant one transient failure (bun
# error, disk full, broken template) left every brain-aware
# SKILL.md link dangling — the whole skill set vanished from
# Claude Code until a successful re-render. A failed render now
# leaves the previous render fully intact.
RENDER_TMP="$RENDER_DIR.tmp.$$"
rm -rf "$RENDER_TMP"
if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" >/dev/null 2>&1 ); then
_swap_in_render "$RENDER_DIR" "$RENDER_TMP"
# Repoint installed skills at the render — gstack-relink prefers
# the render dir when present.
"$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true
echo "Rendered brain-aware blocks into $RENDER_DIR — now live across all your projects' Claude sessions."
echo "The install checkout stays clean: upgrades no longer stash generated render dirt (#2569)."
else
rm -rf "$RENDER_TMP"
echo "Warning: render failed — previous render (if any) left in place, links stay valid."
echo "Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude --out-dir $RENDER_DIR' manually to see the error."
fi
fi
;;
*)
+174 -72
View File
@@ -2,6 +2,24 @@
# gstack-diff-scope — categorize what changed in the diff against a base branch
# Usage: source <(gstack-diff-scope main) → sets SCOPE_FRONTEND=true SCOPE_BACKEND=false ...
# Or: gstack-diff-scope main → prints SCOPE_*=... lines
#
# Output contract (#2526 — all-false must be distinguishable from "we could
# not look" and from "nothing matched"):
# exit 0 changed-file set empty → all false, legitimately nothing
# exit 0 changed files, >=1 category match → flags
# exit 2 changed files, ZERO matches → flags + SCOPE_ERROR=unmatched
# (+ the unmatched paths as comment lines, so a new top-level layout
# trips loudly instead of silently disabling reviewers)
# exit 2 base ref unresolvable → all false + SCOPE_ERROR=no_base
# (shallow CI checkout / missing fetch — a green here would mean
# "we could not look")
# Every line is shell-safe for `source <(...)` consumers: assignments or
# `#`-comments only.
#
# The changed-file set is the UNION of committed diff + working tree +
# untracked files (#2299): /ship detects scope in Step 9, BEFORE it commits in
# Step 15, so uncommitted work must be visible or every scope-gated reviewer
# is skipped on the common start-work-then-ship flow.
set -euo pipefail
# Detect the repo's default branch when no arg is given (#703-class
@@ -14,22 +32,6 @@ _default_base() {
}
BASE="${1:-$(_default_base)}"
# Get changed file list
FILES=$(git diff "${BASE}...HEAD" --name-only 2>/dev/null || git diff "${BASE}" --name-only 2>/dev/null || echo "")
if [ -z "$FILES" ]; then
echo "SCOPE_FRONTEND=false"
echo "SCOPE_BACKEND=false"
echo "SCOPE_PROMPTS=false"
echo "SCOPE_TESTS=false"
echo "SCOPE_DOCS=false"
echo "SCOPE_CONFIG=false"
echo "SCOPE_MIGRATIONS=false"
echo "SCOPE_API=false"
echo "SCOPE_AUTH=false"
exit 0
fi
FRONTEND=false
BACKEND=false
PROMPTS=false
@@ -40,62 +42,162 @@ MIGRATIONS=false
API=false
AUTH=false
while IFS= read -r f; do
_print_flags() {
echo "SCOPE_FRONTEND=$FRONTEND"
echo "SCOPE_BACKEND=$BACKEND"
echo "SCOPE_PROMPTS=$PROMPTS"
echo "SCOPE_TESTS=$TESTS"
echo "SCOPE_DOCS=$DOCS"
echo "SCOPE_CONFIG=$CONFIG"
echo "SCOPE_MIGRATIONS=$MIGRATIONS"
echo "SCOPE_API=$API"
echo "SCOPE_AUTH=$AUTH"
}
# Base reachability (#2526): a shallow CI checkout or an unfetched ref makes
# `git diff` return an empty list — all-false with exit 0, a green that means
# "we could not look". Distinguish it before diffing.
if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then
_print_flags
echo "SCOPE_ERROR=no_base"
echo "# base ref '${BASE}' is not resolvable — shallow checkout or missing fetch. Run: git fetch origin ${BASE}"
exit 2
fi
# Changed files, NUL-delimited (#2526 minor: `git diff --name-only` octal-quotes
# non-ASCII paths, and the trailing quote defeats extension globs; -z avoids it).
FILES_LIST=()
_collect() {
local f
while IFS= read -r -d '' f; do
[ -n "$f" ] && FILES_LIST+=("$f")
done
}
# Committed diff vs base (merge-base form; two-dot fallback when no merge base).
_collect < <(git diff -z "${BASE}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE}" --name-only 2>/dev/null || true)
# Working-tree changes (staged + unstaged). `git diff HEAD` fails on a repo
# with no commits; tolerated.
_collect < <(git diff -z HEAD --name-only 2>/dev/null || true)
# Untracked files: a brand-new component/migration/test is exactly what a
# reviewer should see, and /ship commits it in Step 15 regardless.
_collect < <(git ls-files -z --others --exclude-standard 2>/dev/null || true)
if [ "${#FILES_LIST[@]}" -eq 0 ]; then
_print_flags
exit 0
fi
UNMATCHED=()
# Categories are INDEPENDENT booleans (#2299): a single first-match-wins case
# made them mutually exclusive, so Button.test.jsx set FRONTEND but not TESTS
# while util.test.ts set TESTS but not BACKEND — same intent, opposite result,
# purely from arm ordering. Each category now gets its own case; only BACKEND
# stays deliberately exclusive of frontend component/view files.
for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do
m_frontend=false; m_prompts=false; m_tests=false; m_docs=false
m_config=false; m_migrations=false; m_api=false; m_auth=false; m_backend=false
# Frontend: CSS, views, components, templates
case "$f" in
# Frontend: CSS, views, components, templates
*.css|*.scss|*.less|*.sass|*.pcss|*.module.css|*.module.scss) FRONTEND=true ;;
*.tsx|*.jsx|*.vue|*.svelte|*.astro) FRONTEND=true ;;
*.erb|*.haml|*.slim|*.hbs|*.ejs) FRONTEND=true ;;
*.html) FRONTEND=true ;;
tailwind.config.*|postcss.config.*) FRONTEND=true ;;
app/views/*|*/components/*|styles/*|css/*|app/assets/stylesheets/*) FRONTEND=true ;;
# Prompts: prompt builders, system prompts, generation services
*prompt_builder*|*generation_service*|*writer_service*|*designer_service*) PROMPTS=true ;;
*evaluator*|*scorer*|*classifier_service*|*analyzer*) PROMPTS=true ;;
*voice*.rb|*writing*.rb|*prompt*.rb|*token*.rb) PROMPTS=true ;;
app/services/chat_tools/*|app/services/x_thread_tools/*) PROMPTS=true ;;
config/system_prompts/*) PROMPTS=true ;;
# Tests
*.test.*|*.spec.*|*_test.*|*_spec.*) TESTS=true ;;
test/*|tests/*|spec/*|__tests__/*|cypress/*|e2e/*) TESTS=true ;;
# Docs
*.md) DOCS=true ;;
# Config
package.json|package-lock.json|yarn.lock|bun.lock|bun.lockb) CONFIG=true ;;
Gemfile|Gemfile.lock) CONFIG=true ;;
*.yml|*.yaml) CONFIG=true ;;
.github/*) CONFIG=true ;;
requirements.txt|pyproject.toml|go.mod|Cargo.toml|composer.json) CONFIG=true ;;
# Migrations: database migration files
db/migrate/*|*/migrations/*|alembic/*|prisma/migrations/*) MIGRATIONS=true ;;
# API: routes, controllers, endpoints, GraphQL/OpenAPI schemas
*controller*|*route*|*endpoint*|*/api/*) API=true ;;
*.graphql|*.gql|openapi.*|swagger.*) API=true ;;
# Auth: authentication, authorization, sessions, permissions
*auth*|*session*|*jwt*|*oauth*|*permission*|*role*) AUTH=true ;;
# Backend: everything else that's code (excluding views/components already matched)
*.rb|*.py|*.go|*.rs|*.java|*.php|*.ex|*.exs) BACKEND=true ;;
# Non-component TS/JS is backend. Include ESM/CJS (.mjs/.cjs) and
# explicit-module TS (.mts/.cts) — #1810: these matched no category, so an
# ESM/CJS-only PR skipped the backend reviewer entirely.
*.ts|*.js|*.mjs|*.cjs|*.mts|*.cts) BACKEND=true ;;
*.css|*.scss|*.less|*.sass|*.pcss) m_frontend=true ;;
*.tsx|*.jsx|*.vue|*.svelte|*.astro) m_frontend=true ;;
*.erb|*.haml|*.slim|*.hbs|*.ejs) m_frontend=true ;;
*.html) m_frontend=true ;;
tailwind.config.*|postcss.config.*) m_frontend=true ;;
app/views/*|*/components/*|styles/*|css/*|app/assets/stylesheets/*) m_frontend=true ;;
esac
done <<< "$FILES"
echo "SCOPE_FRONTEND=$FRONTEND"
echo "SCOPE_BACKEND=$BACKEND"
echo "SCOPE_PROMPTS=$PROMPTS"
echo "SCOPE_TESTS=$TESTS"
echo "SCOPE_DOCS=$DOCS"
echo "SCOPE_CONFIG=$CONFIG"
echo "SCOPE_MIGRATIONS=$MIGRATIONS"
echo "SCOPE_API=$API"
echo "SCOPE_AUTH=$AUTH"
# Prompts: prompt builders, system prompts, generation services
case "$f" in
*prompt_builder*|*generation_service*|*writer_service*|*designer_service*) m_prompts=true ;;
*evaluator*|*scorer*|*classifier_service*|*analyzer*) m_prompts=true ;;
*voice*.rb|*writing*.rb|*prompt*.rb|*token*.rb) m_prompts=true ;;
app/services/chat_tools/*|app/services/x_thread_tools/*) m_prompts=true ;;
config/system_prompts/*) m_prompts=true ;;
esac
# Tests
case "$f" in
*.test.*|*.spec.*|*_test.*|*_spec.*) m_tests=true ;;
test/*|tests/*|spec/*|__tests__/*|cypress/*|e2e/*) m_tests=true ;;
esac
# Docs
case "$f" in
*.md) m_docs=true ;;
esac
# Config
case "$f" in
package.json|package-lock.json|yarn.lock|bun.lock|bun.lockb) m_config=true ;;
Gemfile|Gemfile.lock) m_config=true ;;
*.yml|*.yaml) m_config=true ;;
.github/*) m_config=true ;;
requirements.txt|pyproject.toml|go.mod|Cargo.toml|composer.json) m_config=true ;;
esac
# Migrations: database migration files. Bare migrations/* covers a
# root-level migrations dir (#2526); db/data covers the Rails data_migrate
# gem's DATA migrations (#2455) — arbitrary Ruby run unattended against
# production data, strictly higher-risk than a schema migration (they also
# match BACKEND below via their extension, as ordinary app code should).
case "$f" in
db/migrate/*|migrations/*|*/migrations/*|alembic/*|prisma/migrations/*) m_migrations=true ;;
db/data/*|data_migrations/*|*/data_migrations/*) m_migrations=true ;;
esac
# API: routes, controllers, endpoints, GraphQL/OpenAPI schemas. Bare api/*
# covers root-level serverless layouts (Vercel functions, Next.js pages/api
# at root) that */api/* silently missed (#2526).
case "$f" in
api/*|*/api/*|*controller*|*route*|*endpoint*) m_api=true ;;
*.graphql|*.gql|openapi.*|swagger.*) m_api=true ;;
esac
# Auth: authentication, authorization, sessions, permissions
case "$f" in
*auth*|*session*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;;
esac
# Backend: code that isn't a frontend component/view file. Includes ESM/CJS
# (.mjs/.cjs) and explicit-module TS (.mts/.cts) — #1810: these matched no
# category, so an ESM/CJS-only PR skipped the backend reviewer entirely.
if [ "$m_frontend" = false ]; then
case "$f" in
*.rb|*.py|*.go|*.rs|*.java|*.php|*.ex|*.exs) m_backend=true ;;
*.ts|*.js|*.mjs|*.cjs|*.mts|*.cts) m_backend=true ;;
esac
fi
[ "$m_frontend" = true ] && FRONTEND=true
[ "$m_prompts" = true ] && PROMPTS=true
[ "$m_tests" = true ] && TESTS=true
[ "$m_docs" = true ] && DOCS=true
[ "$m_config" = true ] && CONFIG=true
[ "$m_migrations" = true ] && MIGRATIONS=true
[ "$m_api" = true ] && API=true
[ "$m_auth" = true ] && AUTH=true
[ "$m_backend" = true ] && BACKEND=true
if [ "$m_frontend" = false ] && [ "$m_prompts" = false ] && [ "$m_tests" = false ] \
&& [ "$m_docs" = false ] && [ "$m_config" = false ] && [ "$m_migrations" = false ] \
&& [ "$m_api" = false ] && [ "$m_auth" = false ] && [ "$m_backend" = false ]; then
UNMATCHED+=("$f")
fi
done
_print_flags
# Changed files but ZERO category matches (#2526): a classifier bug, an
# unrecognised layout, or a new top-level directory would otherwise present
# as "no reviewers needed" with the skip invisible. Trip loudly instead.
if [ "$FRONTEND" = false ] && [ "$BACKEND" = false ] && [ "$PROMPTS" = false ] \
&& [ "$TESTS" = false ] && [ "$DOCS" = false ] && [ "$CONFIG" = false ] \
&& [ "$MIGRATIONS" = false ] && [ "$API" = false ] && [ "$AUTH" = false ]; then
echo "SCOPE_ERROR=unmatched"
printf '%s\n' ${UNMATCHED[@]+"${UNMATCHED[@]}"} | sort -u | head -50 | while IFS= read -r u; do
[ -n "$u" ] && printf '# unmatched: %s\n' "$u"
done
exit 2
fi
+22 -18
View File
@@ -43,18 +43,17 @@ import {
resolveGbrainBin,
readGbrainVersion,
} from "../lib/gbrain-local-status";
import { isTransactionModePooler } from "../lib/gbrain-exec";
import { gbrainConfigDir, isTransactionModePooler } from "../lib/gbrain-exec";
const STATE_DIR = process.env.GSTACK_HOME || join(userHome(), ".gstack");
const SCRIPT_DIR = __dirname;
const CONFIG_BIN = join(SCRIPT_DIR, "gstack-config");
// Honors GBRAIN_HOME — must stay consistent with lib/gbrain-local-status's
// config resolution, or the detect JSON reports gbrain_local_status "ok"
// alongside gbrain_config_exists false for relocated-home users.
const GBRAIN_CONFIG = join(
process.env.GBRAIN_HOME || join(userHome(), ".gbrain"),
"config.json",
);
// Honors GBRAIN_HOME with gbrain's own configDir() semantics (#2521:
// GBRAIN_HOME is a parent dir, `.gbrain` is appended) — must stay consistent
// with lib/gbrain-local-status's config resolution, or the detect JSON
// reports gbrain_local_status "ok" alongside gbrain_config_exists false for
// relocated-home users. Both route through gbrainConfigDir.
const GBRAIN_CONFIG = join(gbrainConfigDir(), "config.json");
const CLAUDE_JSON = join(userHome(), ".claude.json");
function userHome(): string {
@@ -232,8 +231,7 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
/** remote_mcp.mcp_url from gbrain's own config (thin-client marker, #2051). */
function readRemoteMcpUrl(): string {
const gbrainHome = process.env.GBRAIN_HOME || join(userHome(), ".gbrain");
const cfg = tryReadJSON(join(gbrainHome, "config.json")) as
const cfg = tryReadJSON(join(gbrainConfigDir(), "config.json")) as
| { remote_mcp?: { mcp_url?: string } }
| null;
return cfg?.remote_mcp?.mcp_url || "";
@@ -288,17 +286,23 @@ function main(): void {
}
// --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok";
// "timeout" — a slow-but-healthy engine, #1964; or "thin-client" — remote-HTTP
// MCP brain with no local engine by design, #2051 — neither slow nor remote
// must silently suppress brain features), 1 otherwise. Runs detection live
// (never reads the possibly-stale gbrain-detection.json), so callers — setup,
// bin/dev-setup, and `gstack-config gbrain-refresh` — can decide whether to
// render the gbrain :user variant without duplicating the JSON grep.
// Prints nothing on stdout.
// "timeout" — a slow-but-healthy engine, #1964; "thin-client" — remote-HTTP
// MCP brain with no local engine by design, #2051; or "engine-locked" —
// PGLite is single-writer, so a live `gbrain serve` (typically an MCP
// server) holds the embedded DB, #2456 — gbrain is installed and healthy in
// all four; none must silently suppress brain features), 1 otherwise. Runs
// detection live (never reads the possibly-stale gbrain-detection.json), so
// callers — setup, bin/dev-setup, and `gstack-config gbrain-refresh` — can
// decide whether to render the gbrain :user variant without duplicating the
// JSON grep. Prints nothing on stdout.
if (process.argv.includes("--is-ok")) {
const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1";
const status = localEngineStatus({ noCache });
process.exit(status === "ok" || status === "timeout" || status === "thin-client" ? 0 : 1);
process.exit(
status === "ok" || status === "timeout" || status === "thin-client" || status === "engine-locked"
? 0
: 1,
);
}
main();
+38 -2
View File
@@ -84,7 +84,14 @@ if ! $VALIDATE_ONLY; then
# GitHub reachability — fail fast if offline rather than hanging `git clone`.
# --max-time 10, --head (no body), quiet. Status code 200-4xx means we reached
# the server (even 404 is reachability proof).
if ! curl -s --head --max-time 10 https://github.com >/dev/null 2>&1; then
#
# Skipped under --dry-run: a dry run prints a plan and exits without ever
# cloning, so requiring the network buys nothing and costs a real failure mode.
# It made `--dry-run` fail (exit 3, "cannot reach https://github.com") whenever
# the curl lost a race for sockets/DNS — reproducible at ~15% by running 60
# dry-runs concurrently, and the cause of intermittent red in the D5 tests,
# which call this exact path.
if ! $DRY_RUN && ! curl -s --head --max-time 10 https://github.com >/dev/null 2>&1; then
fail "cannot reach https://github.com. Check your network and try again."
fi
fi
@@ -168,6 +175,26 @@ if ! $VALIDATE_ONLY; then
( cd "$INSTALL_DIR" && bun link --silent )
fi
# #2487: an npm-installed bun (`npm i -g bun`) puts a POSIX script + .cmd/.ps1
# shims on %PATH% but never bun.exe — and the gbrain.exe shim that `bun link`
# generates resolves bun.exe SPECIFICALLY. Link "succeeds", then every gbrain
# call dies with bun's misleading "bun is not installed in %PATH%" (suggesting
# a second parallel bun install). Detect the condition and name the real fix:
# bun's own process.execPath IS the hidden bun.exe.
_bun_exe_hint() {
[ "$IS_WINDOWS" -eq 1 ] || return 0
command -v bun.exe >/dev/null 2>&1 && return 0
local real_bun
real_bun=$(bun -e 'console.log(process.execPath)' 2>/dev/null | tr -d '\r' || true)
echo " detected: bun was installed via npm — bun.exe is NOT on %PATH%, and the gbrain.exe shim needs it." >&2
if [ -n "$real_bun" ]; then
echo " fix: add bun.exe's directory to PATH (persist it in your shell profile):" >&2
echo " export PATH=\"$(dirname "$real_bun"):\$PATH\"" >&2
else
echo " fix: install bun via the official installer (https://bun.sh) or add the directory containing bun.exe to %PATH%." >&2
fi
}
# --- D19 PATH-shadowing validation ---
# Read the version from the install-dir's package.json; compare to
# `gbrain --version`. If they disagree, PATH is returning a DIFFERENT
@@ -178,11 +205,13 @@ if [ -z "$expected_version" ]; then
fi
if ! command -v gbrain >/dev/null 2>&1; then
_bun_exe_hint
fail "bun link completed but 'gbrain' is not on PATH. Ensure ~/.bun/bin is in your PATH."
fi
actual_version=$(gbrain --version 2>/dev/null | head -1 | awk '{print $NF}' | tr -d '[:space:]' || true)
if [ -z "$actual_version" ]; then
_bun_exe_hint
fail "gbrain is on PATH but 'gbrain --version' produced no output — the binary may be broken."
fi
@@ -235,7 +264,14 @@ fi
# a hard gate so a broken gbrain is caught at setup, not at data-loss time.
# Pre-init installs skip this (config not written yet); the full
# `/sync-gbrain --dry-run` self-test runs from /setup-gbrain after `gbrain init`.
_GBRAIN_HOME_CHECK="${GBRAIN_HOME:-$HOME/.gbrain}"
# #2521: GBRAIN_HOME is a PARENT dir per gbrain's configDir() contract —
# gbrain appends `.gbrain` itself, so the config lives at
# $GBRAIN_HOME/.gbrain/config.json (or ~/.gbrain/config.json when unset).
if [ -n "${GBRAIN_HOME:-}" ]; then
_GBRAIN_HOME_CHECK="$GBRAIN_HOME/.gbrain"
else
_GBRAIN_HOME_CHECK="$HOME/.gbrain"
fi
if [ -f "$_GBRAIN_HOME_CHECK/config.json" ]; then
if ! gbrain doctor --fast >/dev/null 2>&1; then
echo "" >&2
+127 -39
View File
@@ -29,7 +29,7 @@
* than building a gstack-side daemon.
*/
import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, renameSync } from "fs";
import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, renameSync, realpathSync } from "fs";
import { join, dirname } from "path";
import { execSync, spawnSync } from "child_process";
import { homedir, hostname } from "os";
@@ -41,7 +41,7 @@ import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleComplet
import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards";
import { writeReceipt } from "../lib/egress-receipt";
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS, bashScriptInvocation } from "../lib/gbrain-exec";
import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client";
import { checkOwnedStagingDir } from "../lib/staging-guard";
@@ -368,6 +368,42 @@ function deriveCodeSourceId(repoPath: string): string {
return constrainSourceId("gstack-code", `${base}-${hostPathHash}`);
}
/**
* Reuse an explicit repo pin when it names a registered source for this exact
* checkout. The path check prevents a stale or copied dotfile from redirecting
* a code sync into another repo's source.
*/
function readPinnedSourceId(repoPath: string): string | null {
const pinPath = join(repoPath, ".gbrain-source");
if (!existsSync(pinPath)) return null;
try {
const sourceId = readFileSync(pinPath, "utf-8").trim();
return /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/.test(sourceId) ? sourceId : null;
} catch {
// A pin is advisory. A permission race or a directory at this path must
// not turn a sync preview into an unexpected crash.
return null;
}
}
export function existingPinnedSourceId(repoPath: string, env?: NodeJS.ProcessEnv): string | null {
const sourceId = readPinnedSourceId(repoPath);
if (!sourceId) return null;
const registeredPath = sourceLocalPath(sourceId, env);
if (!registeredPath) return null;
try {
return realpathSync(registeredPath) === realpathSync(repoPath) ? sourceId : null;
} catch {
return null;
}
}
function resolveCodeSourceId(repoPath: string, env?: NodeJS.ProcessEnv): string {
return existingPinnedSourceId(repoPath, env) ?? deriveCodeSourceId(repoPath);
}
/**
* Pre-pathhash source id, kept for orphan detection only.
*
@@ -820,7 +856,13 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
return { name: "code", ran: false, ok: true, duration_ms: 0, summary: "skipped (not in git repo)" };
}
const sourceId = deriveCodeSourceId(root);
// A preview must not spawn gbrain. Trust a syntactically-valid local pin
// there; a real run confirms its registered path before using it.
const gbrainEnv = args.mode === "dry-run" ? undefined : buildGbrainEnv({ announce: !args.quiet });
const pinnedSourceId = args.mode === "dry-run"
? readPinnedSourceId(root)
: existingPinnedSourceId(root, gbrainEnv);
const sourceId = pinnedSourceId ?? deriveCodeSourceId(root);
// Per-repo trust tier — checked BEFORE the dry-run branch so previews report
// the refusal honestly instead of claiming they would sync.
@@ -861,7 +903,9 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
ran: false,
ok: true,
duration_ms: 0,
summary: `would: gbrain sources add ${sourceId} --path ${root} --federated; gbrain sync --strategy code --source ${sourceId}; gbrain sources attach ${sourceId}`,
summary: pinnedSourceId
? `would: gbrain sync --strategy code --source ${sourceId}; gbrain sources attach ${sourceId}`
: `would: gbrain sources add ${sourceId} --path ${root} --federated; gbrain sync --strategy code --source ${sourceId}; gbrain sources attach ${sourceId}`,
detail: { source_id: sourceId, source_path: root, status: "skipped" },
};
}
@@ -889,10 +933,9 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
// gbrainEnv seeds DATABASE_URL from gbrain's config so this stage works
// inside Next.js / Prisma / Rails projects with their own .env.local
// (codex review #7 — bug fix is wider than #1508 as filed).
const gbrainEnv = buildGbrainEnv({ announce: !args.quiet });
const legacyId = deriveLegacyCodeSourceId(root);
let legacyRemoved = false;
if (legacyId !== sourceId) {
if (!pinnedSourceId && legacyId !== sourceId) {
// #1734: route through the data-loss guards (autopilot + source-safety).
const rm = safeSourcesRemove(legacyId, gbrainEnv);
if (rm.skipped && !args.quiet) {
@@ -908,7 +951,9 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
// pages); fall back to register-new → sync-OK → remove-old. Path-drift
// (user moved the repo, etc.) skips migration with a warning.
const pathOnlyHashLegacyId = derivePathOnlyHashLegacyId(root);
const migration = planHostnameFoldMigration(root, sourceId, pathOnlyHashLegacyId, gbrainEnv);
const migration = pinnedSourceId
? { kind: "none", reason: "no-legacy-source" } as const
: planHostnameFoldMigration(root, sourceId, pathOnlyHashLegacyId, gbrainEnv);
if (migration.kind === "skipped-path-drift" && !args.quiet) {
console.error(
`[sync:code] hostname-fold migration skipped: legacy source ${migration.oldId} `
@@ -919,21 +964,24 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
console.error(`[sync:code] hostname-fold migration: renamed ${migration.oldId}${migration.newId} (pages preserved)`);
}
// Step 1: Ensure source registered (idempotent). Single source of truth in lib —
// no synchronous duplicate here (per /codex review #12).
// Step 1: Ensure generated sources are registered. A confirmed explicit pin
// belongs to the user: its realpath was checked above, so never remove/add it
// merely because the registered spelling differs (e.g. a symlinked checkout).
let registered = false;
try {
const result = await ensureSourceRegistered(sourceId, root, { federated: true, env: gbrainEnv });
registered = result.changed;
} catch (err) {
return {
name: "code",
ran: true,
ok: false,
duration_ms: Date.now() - t0,
summary: `source registration failed: ${(err as Error).message}`,
detail: { source_id: sourceId, source_path: root, status: "failed" },
};
if (!pinnedSourceId) {
try {
const result = await ensureSourceRegistered(sourceId, root, { federated: true, env: gbrainEnv });
registered = result.changed;
} catch (err) {
return {
name: "code",
ran: true,
ok: false,
duration_ms: Date.now() - t0,
summary: `source registration failed: ${(err as Error).message}`,
detail: { source_id: sourceId, source_path: root, status: "failed" },
};
}
}
// Step 2: Always run the page-creating file walk first, then (for --full)
@@ -995,7 +1043,25 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
};
}
const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], {
// `--full` must do a FULL walk, not a delta one.
//
// A bare `sync --strategy code` is incremental: it only revisits files that
// changed since the source's checkpoint. So a file missed at the ORIGINAL
// import is never revisited and stays invisible indefinitely — and the
// reindex-code pass below cannot rescue it, because it re-chunks pages that
// already exist and never walks the filesystem (the same property the comment
// above already relies on).
//
// The failure is silent: no error, no warning, and the verdict block still
// reports OK while `gbrain search` and `gbrain code-def` answer out of a
// partial index. It presents as "gbrain is weak at code questions" rather
// than "the index is incomplete", which is what makes it hard to spot.
//
// --yes because this is spawned non-interactively; a full walk otherwise
// prompts to confirm the import cost.
const walkArgs = ["sync", "--strategy", "code", "--source", sourceId];
if (args.mode === "full") walkArgs.push("--full", "--yes");
const walkResult = spawnGbrain(walkArgs, {
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
timeout: codeTimeoutMs,
baseEnv: gbrainEnv,
@@ -1007,7 +1073,7 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
ran: true,
ok: false,
duration_ms: Date.now() - t0,
summary: `gbrain sync --strategy code --source ${sourceId} exited ${walkResult.status}`,
summary: `gbrain ${walkArgs.join(" ")} exited ${walkResult.status}`,
detail: { source_id: sourceId, source_path: root, status: "failed" },
};
}
@@ -1245,18 +1311,31 @@ function runBrainSyncPush(args: CliArgs): StageResult {
return { name: "brain-sync", ran: false, ok: true, duration_ms: 0, summary: "skipped (gstack-brain-sync not installed)" };
}
// #1731: gstack-brain-sync is a bash shebang script; Windows can't spawn it
// without a shell, which surfaced as "brain-sync exited undefined".
spawnSync(brainSyncPath, ["--discover-new"], {
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
timeout: 60 * 1000,
shell: NEEDS_SHELL_ON_WINDOWS,
});
const result = spawnSync(brainSyncPath, ["--once"], {
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
timeout: 60 * 1000,
shell: NEEDS_SHELL_ON_WINDOWS,
});
// gstack-brain-sync is a bash shebang script, so it needs an INTERPRETER, not
// a shell. #1731 gave it `shell: NEEDS_SHELL_ON_WINDOWS`, which is right for
// the gbrain.cmd shim and useless here: cmd.exe resolves .cmd/.bat via PATHEXT
// and rejects an extension-less shebang script outright ("is not recognized as
// an internal or external command"), so this stage failed on EVERY Windows run
// while looking like a single red line in an otherwise green report. See
// bashScriptInvocation.
const discover = bashScriptInvocation(brainSyncPath, ["--discover-new"]);
const once = bashScriptInvocation(brainSyncPath, ["--once"]);
if (!discover || !once) {
return {
name: "brain-sync",
ran: false,
ok: true,
duration_ms: Date.now() - t0,
summary: "skipped (no bash found; set GSTACK_BASH to your Git bash.exe)",
};
}
const stdio: "ignore"[] | ("ignore" | "inherit")[] = args.quiet
? ["ignore", "ignore", "ignore"]
: ["ignore", "inherit", "inherit"];
spawnSync(discover.cmd, discover.argv, { stdio, timeout: 60 * 1000, shell: discover.shell });
const result = spawnSync(once.cmd, once.argv, { stdio, timeout: 60 * 1000, shell: once.shell });
return {
name: "brain-sync",
@@ -1305,7 +1384,7 @@ export async function runDream(args: CliArgs): Promise<StageResult> {
if (args.mode === "dry-run") {
const root = repoRoot();
const sourceId = root ? deriveCodeSourceId(root) : null;
const sourceId = root ? readPinnedSourceId(root) ?? deriveCodeSourceId(root) : null;
return {
name: "dream",
ran: false,
@@ -1317,6 +1396,7 @@ export async function runDream(args: CliArgs): Promise<StageResult> {
};
}
const gbrainEnv = buildGbrainEnv({ announce: !args.quiet });
const localStatus = localEngineStatus({ noCache: false });
if (localStatus === "timeout") {
warnProbeTimeout("dream"); // #1964: slow-but-healthy — proceed
@@ -1352,7 +1432,7 @@ export async function runDream(args: CliArgs): Promise<StageResult> {
// code-callers/code-callees for this worktree. Falls back to plain `dream`
// only when we can't derive the source id (not in a git repo).
const root = repoRoot();
const sourceId = root ? deriveCodeSourceId(root) : null;
const sourceId = root ? resolveCodeSourceId(root, gbrainEnv) : null;
const dreamArgs = sourceId ? ["dream", "--source", sourceId] : ["dream"];
// spawnGbrain seeds DATABASE_URL from gbrain's config via buildGbrainEnv.
@@ -1481,7 +1561,14 @@ export function parseResolvedEdges(out: string): number | null {
export function classifyDreamOutcome(out: string): string | null {
// The active schema pack doesn't declare the code-symbol extraction phase, so
// no symbols are extracted and resolve_symbol_edges has nothing to match.
if (/does not declare this phase/i.test(out)) {
// #2341: anchor the match to a GRAPH phase. The bare phrase false-positived
// on every base-pack brain — gbrain's only emitters of "active pack does not
// declare this phase" are the CONTENT phases (extract_atoms,
// synthesize_concepts), which base packs legitimately skip while
// resolve_symbol_edges still runs and builds the graph. Matching the bare
// phrase sent users pack-churning ("switch schema packs") for nothing and
// masked real graph bugs behind a wrong diagnosis.
if (/(resolve_symbol_edges|extract_code_symbols)[^\n]*does not declare/i.test(out)) {
return (
"dream ran, but this source's schema pack does not extract code symbols, " +
"so the call graph stays empty. Switch this source to a code-aware schema " +
@@ -1644,7 +1731,8 @@ async function main(): Promise<void> {
let cycle: CycleStatus | null = null;
if (!args.dream && args.mode === "full" && !args.noDream && !args.noCode) {
const root = repoRoot();
cycle = root ? cycleCompleted(deriveCodeSourceId(root), process.env) : "unknown";
const gbrainEnv = buildGbrainEnv({ announce: !args.quiet });
cycle = root ? cycleCompleted(resolveCodeSourceId(root, gbrainEnv), gbrainEnv) : "unknown";
}
if (shouldRunDream(args, cycle)) {
dreamStage = await runDream(args);
+14 -2
View File
@@ -543,7 +543,7 @@ interface ParsedSession {
partial: boolean;
}
function parseTranscriptJsonl(path: string): ParsedSession | null {
export function parseTranscriptJsonl(path: string): ParsedSession | null {
// Best-effort tolerant parser. Handles truncated last lines (D10 partial-flag).
let raw: string;
try {
@@ -619,7 +619,7 @@ function parseTranscriptJsonl(path: string): ParsedSession | null {
const tool = rec?.name || rec?.tool || rec?.tool_call?.name || "tool";
bodyParts.push(`### Tool call: ${tool}`);
} else if (isCodex && rec?.payload?.message) {
// Codex shape: each record has payload.message
// Legacy Codex shape: each record has payload.message
const msg = rec.payload.message;
const role = msg.role || "user";
const content = extractContentText(msg);
@@ -627,6 +627,18 @@ function parseTranscriptJsonl(path: string): ParsedSession | null {
bodyParts.push(`## ${role.charAt(0).toUpperCase() + role.slice(1)}\n\n${content}`);
messageCount++;
}
} else if (isCodex && rec?.type === "response_item" && rec?.payload?.type === "message") {
// Current Codex rollout shape (#2105): records are
// { type: 'response_item', payload: { type: 'message', role, content: [...] } }.
// The legacy payload.message branch never fires on these, which rendered
// every Codex session as an empty shell (message_count: 0, 243/243 on
// the reporting machine). Flatten payload.content like the Claude branch.
const role = rec.payload.role || "user";
const content = extractContentText(rec.payload);
if (content) {
bodyParts.push(`## ${role.charAt(0).toUpperCase() + role.slice(1)}\n\n${content}`);
messageCount++;
}
}
}
+152 -47
View File
@@ -19,6 +19,13 @@
// committed so all collaborators benefit)
// 3. "VERSION" at the repo root (default, backward-compatible)
//
// The pinned path may be a package.json (any depth) rather than a plain-text
// VERSION file: a path ending in .json is read as JSON and its .version taken.
// 3-digit semver is accepted as well as 4-digit, and stays 3-digit through
// bumping. See lib/version-source.ts for why both mattered — each used to fail
// closed, which silently disabled the queue-collision check this CLI exists to
// provide (#2501).
//
// Exit codes:
// 0 — emitted JSON successfully (may include "offline":true or "host":"unknown")
// 2 — invalid arguments
@@ -28,9 +35,18 @@ import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
type Bump = "major" | "minor" | "patch" | "micro";
type Version = [number, number, number, number];
import {
parseVersion,
versionWidth,
fmtVersion,
bumpVersion,
cmpVersion,
bumpWasCoerced,
extractVersion,
type Bump,
type Version,
type VersionWidth,
} from "../lib/version-source";
type ClaimedPR = {
pr: number;
@@ -56,6 +72,7 @@ type Output = {
bump: Bump;
host: "github" | "gitlab" | "unknown";
offline: boolean;
fallback: "git" | null;
claimed: ClaimedPR[];
siblings: Sibling[];
active_siblings: Sibling[];
@@ -66,48 +83,20 @@ type Output = {
const ACTIVE_SIBLING_MAX_AGE_S = 24 * 60 * 60;
const GH_API_CONCURRENCY = 10;
function parseVersion(s: string): Version | null {
const m = s.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (!m) return null;
return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
}
function fmtVersion(v: Version): string {
return v.join(".");
}
function bumpVersion(v: Version, level: Bump): Version {
switch (level) {
case "major":
return [v[0] + 1, 0, 0, 0];
case "minor":
return [v[0], v[1] + 1, 0, 0];
case "patch":
return [v[0], v[1], v[2] + 1, 0];
case "micro":
return [v[0], v[1], v[2], v[3] + 1];
}
}
function cmpVersion(a: Version, b: Version): number {
for (let i = 0; i < 4; i++) {
if (a[i] !== b[i]) return a[i] - b[i];
}
return 0;
}
// Collision resolution: bump past the highest claimed within the same level.
// Semantics: if my bump is MINOR and the queue claims 1.7.0.0, I advance to
// 1.8.0.0 (still a MINOR relative to main). Preserves ship-time intent.
function pickNextSlot(base: Version, claimed: Version[], level: Bump): { version: Version; reason: string } {
let candidate = bumpVersion(base, level);
// `width` keeps a 3-digit repo 3-digit (see lib/version-source.ts); it
// defaults to 4 so existing callers and tests are unaffected.
function pickNextSlot(base: Version, claimed: Version[], level: Bump, width: VersionWidth = 4): { version: Version; reason: string } {
let candidate = bumpVersion(base, level, width);
const sortedClaimed = [...claimed].sort(cmpVersion);
const highest = sortedClaimed[sortedClaimed.length - 1];
if (highest && cmpVersion(highest, base) > 0) {
// Queue already advanced past base; bump past the highest claim.
const bumpedPastHighest = bumpVersion(highest, level);
const bumpedPastHighest = bumpVersion(highest, level, width);
if (cmpVersion(bumpedPastHighest, candidate) > 0) {
return { version: bumpedPastHighest, reason: `bumped past claimed ${fmtVersion(highest)}` };
return { version: bumpedPastHighest, reason: `bumped past claimed ${fmtVersion(highest, width)}` };
}
}
return { version: candidate, reason: "no collision; clean bump from base" };
@@ -167,7 +156,12 @@ function readBaseVersion(base: string, versionPath: string, warnings: string[]):
warnings.push(`could not read ${versionPath} at origin/${base}; assuming 0.0.0.0`);
return "0.0.0.0";
}
return r.stdout.trim();
const v = extractVersion(r.stdout, versionPath);
if (!v) {
warnings.push(`${versionPath} at origin/${base} has no readable version; assuming 0.0.0.0`);
return "0.0.0.0";
}
return v;
}
async function fetchGithubClaimed(base: string, versionPath: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> {
@@ -233,7 +227,7 @@ async function fetchGithubClaimed(base: string, versionPath: string, excludePR:
}
let versionStr: string;
try {
versionStr = Buffer.from(content.stdout.trim(), "base64").toString("utf8").trim();
versionStr = extractVersion(Buffer.from(content.stdout.trim(), "base64").toString("utf8"), versionPath);
} catch {
warnings.push(`PR #${pr.number}: VERSION is not valid base64`);
continue;
@@ -290,7 +284,7 @@ async function fetchGitlabClaimed(base: string, versionPath: string, excludePR:
}
try {
const j = JSON.parse(content.stdout);
const versionStr = Buffer.from(j.content, "base64").toString("utf8").trim();
const versionStr = extractVersion(Buffer.from(j.content, "base64").toString("utf8"), versionPath);
if (!parseVersion(versionStr)) {
warnings.push(`MR !${mr.iid}: VERSION malformed (${versionStr})`);
continue;
@@ -349,7 +343,7 @@ function scanSiblings(root: string | null, versionPath: string, claimed: Claimed
if (!existsSync(versionFile)) continue;
let version: string;
try {
version = readFileSync(versionFile, "utf8").trim();
version = extractVersion(readFileSync(versionFile, "utf8"), versionPath);
if (!parseVersion(version)) continue;
} catch {
continue;
@@ -452,6 +446,84 @@ function autoDetectExcludePR(): number | null {
return Number.isFinite(n) && n > 0 ? n : null;
}
// ── git-only fallback (#2545) ────────────────────────────────────────────
//
// When the host query fails this util used to return `offline:true` with an
// EMPTY claim set, and /ship's instruction was "fall back to local BUMP_LEVEL
// arithmetic". Local arithmetic cannot see a sibling's claim, so the fallback
// allocated a version another open PR already held.
//
// That is not hypothetical. On 2026-08-12 in a downstream repo, `gh pr list`
// failed during a ship, this util reported offline, the bump fell back to
// local arithmetic, and 0.1.57.0 was allocated to a second PR while an open
// one already claimed it — both merged, and main carries two commits reading
// v0.1.57.0. Auditing that repo's history found FOUR such pairs going back
// three weeks, so the silent fallback had been mis-allocating for a while.
//
// Git already knows what the API was asked for. Remote-tracking refs carry
// each branch's VERSION file, and the base's own history records every version
// already shipped. Neither needs a token, a network round-trip, or a working
// `gh`. So "offline" degrades the QUEUE VIEW (no PR numbers, no draft status)
// without degrading the ALLOCATION.
function fetchGitClaimed(
base: string,
versionPath: string,
warnings: string[],
): ClaimedPR[] {
const claims: ClaimedPR[] = [];
// 1. Every remote-tracking branch's VERSION file. These are the open PRs'
// branches, whether or not the API can be reached to enumerate them.
// Read through extractVersion so a JSON version-path (#2501) resolves on
// remote refs too, and the branch's own width is preserved in the claim.
const refs = runCommand("git", [
"for-each-ref",
"--format=%(refname:short)",
"refs/remotes",
]);
if (refs.ok) {
const baseShort = base.replace(/^origin\//, "");
for (const ref of refs.stdout.split("\n").map((r) => r.trim()).filter(Boolean)) {
if (ref.endsWith("/HEAD")) continue;
if (ref === base || ref.replace(/^origin\//, "") === baseShort) continue;
const show = runCommand("git", ["show", `${ref}:${versionPath}`]);
if (!show.ok) continue;
const raw = extractVersion(show.stdout, versionPath);
if (!raw || !parseVersion(raw)) continue;
claims.push({ pr: 0, branch: ref, version: raw });
}
} else {
warnings.push("git for-each-ref failed; branch claims unavailable");
}
// 2. Versions already shipped, read from the base's commit subjects. Catches
// the case the VERSION file cannot: a number that merged and was then
// re-picked. Bounded, and it says so rather than implying full history.
const SUBJECT_SCAN = 400;
const log = runCommand("git", ["log", `-n${SUBJECT_SCAN}`, "--format=%s", base]);
if (log.ok) {
for (const subject of log.stdout.split("\n")) {
const m = subject.trim().match(/^v(\d+\.\d+\.\d+(?:\.\d+)?)\b/);
if (!m) continue;
if (!parseVersion(m[1])) continue;
claims.push({ pr: 0, branch: `(shipped on ${base})`, version: m[1] });
}
// A cap that does not announce itself reads as "checked all history".
// Only fires when the log came back exactly full, which is the only
// observable signal that older commits went unread.
if (log.stdout.trim().split("\n").length >= SUBJECT_SCAN) {
warnings.push(
`shipped-version scan stopped at ${SUBJECT_SCAN} commits on ${base}; ` +
`a version shipped before that is not counted as claimed`,
);
}
} else {
warnings.push(`git log ${base} failed; shipped-version scan unavailable`);
}
return claims;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
@@ -469,6 +541,13 @@ async function main() {
console.error(`Error: could not parse base version '${baseVersion}'`);
process.exit(2);
}
// The repo's own width governs everything downstream: a 3-digit repo must
// not be handed a 4-digit slot, or /ship writes a version the repo's tooling
// can't read back (#2501).
const width = versionWidth(baseVersion);
if (bumpWasCoerced(args.bump, width)) {
warnings.push(`--bump micro has no component to move in a ${width}-digit version; treated as patch`);
}
const excludePR = args.excludePR ?? autoDetectExcludePR();
if (excludePR !== null && args.excludePR === null) {
@@ -485,6 +564,28 @@ async function main() {
warnings.push("host unknown; queue-awareness unavailable");
}
// Degraded host query → fall back to git, which needs no API. Additive: it
// only runs when the host told us nothing, so the online path is untouched.
let fallback: "git" | null = null;
if (offline || host === "unknown") {
const gitClaims = fetchGitClaimed(args.base, versionPath, warnings);
if (gitClaims.length) {
claimed = [...claimed, ...gitClaims];
fallback = "git";
warnings.push(
`host queue unavailable — allocated from git instead ` +
`(${gitClaims.length} claim(s) from remote refs + shipped subjects). ` +
`PR numbers and draft status are unavailable, but the version is safe.`,
);
} else {
warnings.push(
"host queue unavailable AND git found no claims — the pick rests on " +
"the base VERSION alone. Verify no sibling branch holds it before " +
"shipping.",
);
}
}
// Only count PRs that actually bumped VERSION past base as real "claims".
// A PR whose VERSION equals base's VERSION hasn't claimed anything.
const realClaims = claimed.filter((c) => {
@@ -495,7 +596,7 @@ async function main() {
.map((c) => parseVersion(c.version))
.filter((v): v is Version => v !== null);
const { version: picked, reason } = pickNextSlot(baseParsed, claimedVersions, args.bump);
const { version: picked, reason } = pickNextSlot(baseParsed, claimedVersions, args.bump, width);
const workspaceRoot = resolveWorkspaceRoot(args.workspaceRoot);
const siblings = markActiveSiblings(scanSiblings(workspaceRoot, versionPath, claimed, warnings), baseParsed);
@@ -510,18 +611,19 @@ async function main() {
.filter((v) => cmpVersion(v, finalVersion) >= 0);
if (activeAhead.length) {
const highest = activeAhead.sort(cmpVersion)[activeAhead.length - 1];
finalVersion = bumpVersion(highest, args.bump);
finalReason = `bumped past active sibling ${fmtVersion(highest)}`;
finalVersion = bumpVersion(highest, args.bump, width);
finalReason = `bumped past active sibling ${fmtVersion(highest, width)}`;
}
const out: Output = {
version: fmtVersion(finalVersion),
version: fmtVersion(finalVersion, width),
current_version: args.current || baseVersion,
base_version: baseVersion,
version_path: versionPath,
bump: args.bump,
host,
offline,
fallback,
claimed: realClaims,
siblings,
active_siblings: activeSiblings,
@@ -531,8 +633,11 @@ async function main() {
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
}
// Pure-function exports for testing
export { parseVersion, fmtVersion, bumpVersion, cmpVersion, pickNextSlot, markActiveSiblings, resolveVersionPath };
// Pure-function exports for testing. The version primitives are re-exported
// from lib/version-source so existing importers of this module keep working
// unchanged.
export { parseVersion, fmtVersion, bumpVersion, cmpVersion, versionWidth, extractVersion };
export { pickNextSlot, markActiveSiblings, resolveVersionPath, fetchGitClaimed };
// Only run main() when invoked as a script, not when imported by tests.
if (import.meta.main) {
+128 -7
View File
@@ -70,6 +70,33 @@ function objectExists(sha: string): boolean {
return r.status === 0;
}
/**
* The remote-tracking exclusion used when narrowing to "commits new to the
* remote" (#2592 catch-up merges, #2573 rebased force-pushes).
*
* Narrowed to the PUSH TARGET's namespace (S1): a bare `--remotes` excludes
* commits reachable from ANY remote-tracking ref, so a secret that had only
* ever been fetched from (or pushed to) a private/local-path remote was never
* scanned when later pushed to a PUBLIC remote — "already left this machine"
* is not "already reached THIS remote". Git hands pre-push the push remote's
* name as $1 (and its URL as $2); the installed hook wrapper forwards "$@".
* Fallbacks keep the historical all-remotes behavior when the name is
* unavailable (stdin/CLI invocation) or is not a configured remote (URL
* pushes have no remote-tracking namespace) — falling back scans LESS than
* the narrowed form would, but never less than the hook historically did.
*/
let _remotesExclusion: string | undefined;
function remotesExclusion(): string {
if (_remotesExclusion === undefined) {
const name = process.argv[2];
const configured = name
? git(["remote"]).split("\n").map((s) => s.trim()).filter(Boolean).includes(name)
: false;
_remotesExclusion = configured ? `--remotes=${name}/*` : "--remotes";
}
return _remotesExclusion;
}
function defaultRemoteBranch(): string {
// origin/HEAD → origin/main, fall back to main/master.
const sym = git(["symbolic-ref", "refs/remotes/origin/HEAD"]).trim();
@@ -104,12 +131,12 @@ function unknownRemoteTipBase(localSha: string): string | null {
// engine's byte cap, so `engine.input_too_large` blocks having scanned
// NOTHING — "scans more, never less" inverted into "scans nothing".
//
// `--remotes` covers every remote, not just the push target: content
// already published anywhere has already left this machine, so treating it
// as pre-existing is deliberate. Git hands the remote name to pre-push in
// argv, which this hook does not read; narrowing to it would only matter
// for a repo that pushes secrets to one remote but not another.
const newCommits = git(["rev-list", "--reverse", localSha, "--not", "--remotes"]).trim();
// The exclusion is scoped to the PUSH TARGET's tracking refs (see
// remotesExclusion): content on some OTHER remote has left this machine,
// but it has not reached the remote being pushed to — a secret that only
// ever hit a private remote must still be scanned on its way to a public
// one (S1).
const newCommits = git(["rev-list", "--reverse", localSha, "--not", remotesExclusion()]).trim();
if (newCommits) {
const oldest = newCommits.split("\n")[0];
const parent = git(["rev-parse", "--verify", `${oldest}^`]).trim();
@@ -122,8 +149,90 @@ function unknownRemoteTipBase(localSha: string): string | null {
return null;
}
/**
* The commits this push actually adds — reachable from localSha and from NO
* remote-tracking ref.
*
* ⚠ WHY THIS EXISTS RATHER THAN A TWO-DOT RANGE.
*
* `remoteSha..localSha` is "everything new on this branch", which is NOT the
* same as "everything new to the remote". Merge origin/main into a feature
* branch and every commit main gained since the branch's last push becomes an
* added line — content that is already published, already scanned, and not
* this push's doing.
*
* Two things follow, and both were observed:
*
* · FALSE HIGH FINDINGS. A placeholder connection string in a test fixture,
* already merged to main by someone else, blocked an unrelated push as
* `db.url_with_password` — telling the operator to rotate a credential
* over a fixture they had never touched. A
* guard that cries wolf on catch-up merges is a guard people learn to
* bypass reflexively — which is exactly how a real secret gets through.
* · OVERSIZED SCANS. The comment on SCAN_CHUNK_BYTES below records a
* 1,146,782-byte diff from "a feature branch catching up to a busy main"
* blowing the engine's 1 MiB cap. Same root cause, treated there as a size
* problem and solved by slicing. Narrowing the range fixes the size too.
*
* A two-dot range cannot express this: after merging main, neither the remote
* tip nor the merge-base with main is an ancestor of the other, so no single
* base excludes both. `rev-list --not --remotes=<push-remote>/*` is the
* operation that does, and this file already reasons that way in
* `unknownRemoteTipBase` step 2. The exclusion is scoped to the push target's
* tracking namespace (see remotesExclusion): the upstream commits a catch-up
* merge brings in came from the SAME remote being pushed to, so scoping keeps
* the #2592 fix intact while a secret known only to some OTHER (private)
* remote is still scanned on its way to this one (S1).
*
* Each commit is diffed alone. `--cc` on a merge shows only the conflict
* RESOLUTION — content that exists in no parent — so a secret introduced while
* resolving a merge is still caught, while an ordinary merge contributes
* nothing. Returns null when the notion does not apply, so callers fall back.
*/
function addedLinesFromNewCommits(localSha: string, remoteSha: string): string | null {
// remoteSha is what git TELLS us the remote has, and it is authoritative in a
// way `--remotes` is not: remote-tracking refs can be absent (a fresh clone
// that never fetched, a push to a remote with no tracking ref) or stale. Drop
// it and a repo with no tracking refs excludes NOTHING — every commit ever
// made reads as "new", which re-introduces the false positives from the other
// direction. So it stays the base; `--remotes` only ADDS exclusions on top.
if (ZERO.test(remoteSha) || !objectExists(remoteSha)) return null;
const narrowed = git(["rev-list", localSha, "--not", remoteSha, remotesExclusion()]).trim();
if (!narrowed) return null;
// If excluding remote-tracking refs changes nothing, this push has no
// catch-up commits and the plain range already describes it exactly. Defer to
// it. That is not just an optimization: it keeps every push that ISN'T a
// catch-up merge on the original gitStrict diff path, so the fail-closed
// guarantee (#1946) and its regression test keep exercising the code they
// were written for. A narrowing that silently retired that test would be a
// worse trade than the false positives it set out to fix.
const plain = git(["rev-list", `${remoteSha}..${localSha}`]).trim();
const asSet = (s: string) => s.split("\n").filter(Boolean).sort().join("\n");
if (asSet(narrowed) === asSet(plain)) return null;
const shas = narrowed.split("\n").filter(Boolean);
// A rewrite of long history should fall back rather than shell out per commit.
if (shas.length > 500) return null;
const out: string[] = [];
for (const sha of shas) {
// gitStrict: a failed diff must never read as "nothing added" (#1946).
out.push(gitStrict([
"show", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv",
"--cc", "--format=", sha,
]));
}
return out.join("\n");
}
/** Return the added-line text for a ref update being pushed. */
function addedLinesFor(localSha: string, remoteSha: string): string {
// Preferred ONLY when this push carries catch-up commits: scanning them again
// is the bug. Every other shape falls through to the range logic below.
const fromNew = addedLinesFromNewCommits(localSha, remoteSha);
if (fromNew !== null) return collectAddedLines(fromNew);
let range: string;
if (ZERO.test(remoteSha) || !objectExists(remoteSha)) {
// Either a new branch (zero remote sha), or the remote tip object is absent
@@ -151,6 +260,14 @@ function addedLinesFor(localSha: string, remoteSha: string): string {
"diff", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv",
range,
]);
return collectAddedLines(diff);
}
/**
* Added-line text from a unified diff. Shared by both range strategies so the
* hunk-aware header handling below cannot drift between them.
*/
function collectAddedLines(diff: string): string {
const added: string[] = [];
// Hunk-aware header skip (#2498): `+++ ` is only a FILE HEADER outside a
// hunk. Inside a hunk, an added content line whose text begins with "++"
@@ -158,7 +275,11 @@ function addedLinesFor(localSha: string, remoteSha: string): string {
// silently dropped exactly those lines from the scan.
let inHunk = false;
for (const line of diff.split("\n")) {
if (line.startsWith("diff --git")) { inHunk = false; continue; }
// `diff --` rather than `diff --git`: a merge scanned with --cc emits
// `diff --cc <path>`, so a --git-only reset left inHunk true across file
// boundaries and read the next file's `+++ b/...` header as content. Only
// noise (it over-scans, never under-scans), but the boundary is real.
if (line.startsWith("diff --")) { inHunk = false; continue; }
if (line.startsWith("@@")) { inHunk = true; continue; }
if (!inHunk && (line.startsWith("+++") || line.startsWith("---"))) continue;
if (line.startsWith("+")) added.push(line.slice(1));
+21 -2
View File
@@ -36,6 +36,12 @@ SKILLS_DIR="${GSTACK_SKILLS_DIR:-$(dirname "$INSTALL_DIR")}"
# Read prefix setting
PREFIX=$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || echo "false")
# #2569: rendered :user variants (brain-aware blocks) live in an UNTRACKED
# out-dir instead of the tracked install checkout. When a render exists for a
# skill, relink serves it — otherwise a config change would silently flip
# every skill back to the canonical (blockless) source.
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}"
# Helper: remove old skill entry (symlink or real directory with symlinked SKILL.md)
_cleanup_skill_entry() {
local entry="$1"
@@ -52,7 +58,13 @@ _link_root_skill_alias() {
[ -f "$INSTALL_DIR/SKILL.md" ] || return 0
[ -L "$target" ] && rm -f "$target"
mkdir -p "$target"
ln -snf "$INSTALL_DIR/SKILL.md" "$target/SKILL.md"
# Copy-then-rewrite, never a symlink (#2511): a symlinked alias re-serves
# the canonical `name: gstack`, Claude Code sees a duplicate skill name,
# and drops the ENTIRE personal-skills set. sed reads the source and writes
# a fresh copy — remove any prior symlink first so the redirect can never
# write through it into the generated source.
rm -f "$target/SKILL.md"
sed "1,/^---\$/ s/^name:[[:space:]].*/name: _gstack-command/" "$INSTALL_DIR/SKILL.md" > "$target/SKILL.md"
}
_link_root_skill_alias
@@ -61,6 +73,11 @@ _link_root_skill_alias
SKILL_COUNT=0
for skill_dir in "$INSTALL_DIR"/*/; do
[ -d "$skill_dir" ] || continue
# Skip symlinked skill dirs (connect-chrome → open-gstack-browser): linking
# one under the symlink's basename would duplicate the canonical frontmatter
# name and collide in Claude Code's skill registry (#2201). setup owns the
# rewritten-copy alias for those.
[ -L "${skill_dir%/}" ] && continue
skill=$(basename "$skill_dir")
# Skip non-skill directories
case "$skill" in bin|browse|design|docs|extension|lib|node_modules|scripts|test|.git|.github) continue ;; esac
@@ -87,7 +104,9 @@ for skill_dir in "$INSTALL_DIR"/*/; do
[ -L "$target" ] && rm -f "$target"
# Create real directory with symlinked SKILL.md (absolute path)
mkdir -p "$target"
ln -snf "$INSTALL_DIR/$skill/SKILL.md" "$target/SKILL.md"
skill_md_src="$INSTALL_DIR/$skill/SKILL.md"
[ -f "$RENDER_DIR/$skill/SKILL.md" ] && skill_md_src="$RENDER_DIR/$skill/SKILL.md"
ln -snf "$skill_md_src" "$target/SKILL.md"
SKILL_COUNT=$((SKILL_COUNT + 1))
done
+9 -1
View File
@@ -44,7 +44,15 @@ fi
CACHE_DIR="$HOME/.gstack/projects/$SLUG"
CACHE_FILE="$CACHE_DIR/repo-mode.json"
if [ -f "$CACHE_FILE" ]; then
CACHE_AGE=$(( $(date +%s) - $(stat -f %m "$CACHE_FILE" 2>/dev/null || stat -c %Y "$CACHE_FILE" 2>/dev/null || echo 0) ))
# GNU first (#2195): on GNU coreutils `stat -f` SUCCEEDS with filesystem
# status (not a format string), so the BSD-first fallback chain never fell
# over — it fed multi-word filesystem output into the arithmetic below and
# crashed under set -u on Windows Git Bash. `stat -c` fails cleanly on
# BSD/macOS, making GNU-first the deterministic order. Numeric-validate
# before arithmetic as the last line of defense.
CACHE_MTIME=$(stat -c %Y "$CACHE_FILE" 2>/dev/null || stat -f %m "$CACHE_FILE" 2>/dev/null || echo 0)
case "$CACHE_MTIME" in ''|*[!0-9]*) CACHE_MTIME=0 ;; esac
CACHE_AGE=$(( $(date +%s) - CACHE_MTIME ))
if [ "$CACHE_AGE" -lt 604800 ]; then # 7 days in seconds
MODE=$(grep -o '"mode":"[^"]*"' "$CACHE_FILE" | head -1 | cut -d'"' -f4)
[ -n "$MODE" ] && echo "REPO_MODE=$(validate_mode "$MODE")" && exit 0
+3 -1
View File
@@ -171,8 +171,9 @@ case "$ACTION" in
const matchesEntry = (entry) => {
const sameMatcher = (entry.matcher || "") === matcher;
const sameCommand = entry.hooks && entry.hooks[0] && entry.hooks[0].command === cmd;
const sameSource = entry._gstack_source === source;
return sameMatcher && sameSource;
return sameMatcher && (sameSource || sameCommand);
};
let existing = settings.hooks[event].find(matchesEntry);
@@ -184,6 +185,7 @@ case "$ACTION" in
if (existing) {
existing.hooks = [hookEntry];
existing._gstack_source = source;
} else {
const newEntry = { _gstack_source: source, hooks: [hookEntry] };
if (matcher) newEntry.matcher = matcher;
+12 -2
View File
@@ -27,7 +27,11 @@
# injection when consumed via source or eval.
set -euo pipefail
CACHE_DIR="$HOME/.gstack/slug-cache"
# GSTACK_HOME-aware, matching lib/bin-context.ts's native port (#2561): the
# bash writer and the TS reader must key the SAME cache, and a test running
# with GSTACK_HOME=<temp> must write its cache junk there, not into the real
# home (observed: 2,528 stale temp-cwd entries accumulated in ~/.gstack).
CACHE_DIR="${GSTACK_HOME:-$HOME/.gstack}/slug-cache"
PROJECT_DIR="$(pwd)"
# Encode absolute path as cache key: /Users/j/foo → _Users_j_foo
CACHE_KEY=$(printf '%s' "$PROJECT_DIR" | tr '/' '_')
@@ -37,8 +41,14 @@ SLUG=""
# 0. Explicit env override — wins over everything. Escape hatch for vendored
# sub-repos and other genuine "subdir IS its own project" edge cases.
SLUG_FROM_ENV=0
if [[ -n "${GSTACK_PROJECT_SLUG:-}" ]]; then
SLUG=$(printf '%s' "$GSTACK_PROJECT_SLUG" | tr -cd 'a-zA-Z0-9._-')
# Per-invocation escape hatch, never a durable identity: persisting it
# would rebind THIS cwd's slug for every later env-less run (observed: a
# test exporting GSTACK_PROJECT_SLUG from the repo root rebound the whole
# repo's session state to the test's slug).
SLUG_FROM_ENV=1
fi
# 1. Walk up from pwd, tracking the OUTERMOST ancestor with a canonical
@@ -160,7 +170,7 @@ SLUG="${SLUG:-$(basename "$PROJECT_DIR" | tr -cd 'a-zA-Z0-9._-')}"
# injection, but the invariant should not depend on that reasoning).
SLUG=$(printf '%s' "$SLUG" | tr -cd 'a-zA-Z0-9._-')
if [[ -n "$SLUG" ]]; then
if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 ]]; then
CURRENT_CACHE=""
if [[ -f "$CACHE_FILE" ]]; then
CURRENT_CACHE=$(cat "$CACHE_FILE" 2>/dev/null || true)
+17 -3
View File
@@ -70,7 +70,11 @@ else
**Before doing ANY work, verify gstack is installed:**
```bash
test -d ~/.claude/skills/gstack/bin && echo "GSTACK_OK" || echo "GSTACK_MISSING"
_GS=""
for _D in "${GSTACK_ROOT:-}" "$HOME/.claude/skills/gstack" "$HOME/.codex/skills/gstack" "$HOME/.factory/skills/gstack" "$HOME/.kiro/skills/gstack" "$HOME/.config/opencode/skills/gstack" "$HOME/.slate/skills/gstack" "$HOME/.cursor/skills/gstack" "$HOME/.openclaw/skills/gstack" "$HOME/.hermes/skills/gstack" "$HOME/.gbrain/skills/gstack" "$HOME/.gstack/repos/gstack"; do
[ -z "$_GS" ] && [ -n "$_D" ] && [ -d "$_D/bin" ] && _GS="$_D"
done
[ -n "$_GS" ] && echo "GSTACK_OK: $_GS" || echo "GSTACK_MISSING"
```
If GSTACK_MISSING: STOP. Do not proceed. Tell the user:
@@ -87,7 +91,8 @@ Do not skip skills, ignore gstack errors, or work around missing gstack.
Using gstack skills: After install, skills like /qa, /ship, /review, /investigate,
and /browse are available. Use /browse for all web browsing.
Use ~/.claude/skills/gstack/... for gstack file paths (the global path).'
Use the resolved install path above for gstack file paths
(default: ~/.claude/skills/gstack).'
fi
# Check if CLAUDE.md already has a gstack section
@@ -114,8 +119,17 @@ if [ "$MODE" = "required" ]; then
cat > "$HOOKS_DIR/check-gstack.sh" << 'HOOK_EOF'
#!/bin/bash
# Block skill usage when gstack is not installed globally.
#
# Resolve the install root the way gstack skill preambles do: the GSTACK_ROOT
# env var first, then every host's global install location, then the migrated
# repo location. Block only when NONE exist (#2500 — hardcoding
# ~/.claude/skills/gstack false-blocked Codex-host and migrated-repo installs).
_GSTACK_ROOT=""
for _D in "${GSTACK_ROOT:-}" "$HOME/.claude/skills/gstack" "$HOME/.codex/skills/gstack" "$HOME/.factory/skills/gstack" "$HOME/.kiro/skills/gstack" "$HOME/.config/opencode/skills/gstack" "$HOME/.slate/skills/gstack" "$HOME/.cursor/skills/gstack" "$HOME/.openclaw/skills/gstack" "$HOME/.hermes/skills/gstack" "$HOME/.gbrain/skills/gstack" "$HOME/.gstack/repos/gstack"; do
[ -z "$_GSTACK_ROOT" ] && [ -n "$_D" ] && [ -d "$_D/bin" ] && _GSTACK_ROOT="$_D"
done
if [ ! -d "$HOME/.claude/skills/gstack/bin" ]; then
if [ -z "$_GSTACK_ROOT" ]; then
cat >&2 <<'MSG'
BLOCKED: gstack is not installed globally.
+130 -9
View File
@@ -12,12 +12,14 @@
# ~/.codex/skills/gstack* — Codex skill install + per-skill symlinks
# ~/.factory/skills/gstack* — Factory Droid skill install + per-skill symlinks
# ~/.kiro/skills/gstack* — Kiro skill install + per-skill symlinks
# ~/.cursor/skills/gstack* — Cursor skill install + per-skill symlinks
# ~/.gstack/ — global state (config, analytics, sessions, projects,
# repos, installation-id, browse error logs)
# .claude/skills/gstack* — project-local skill install (--local installs)
# .gstack/ — per-project browse state (in current git repo)
# .gstack-worktrees/ — per-project test worktrees (in current git repo)
# .agents/skills/gstack* — Codex/Gemini/Cursor sidecar (in current git repo)
# .agents/skills/gstack* — Codex/Gemini sidecar (in current git repo)
# .cursor/skills/gstack* — project-local Cursor skills (in current git repo)
# Running browse daemons — stopped via SIGTERM before cleanup
#
# What is NOT REMOVED:
@@ -66,6 +68,7 @@ if [ "$FORCE" -eq 0 ]; then
[ -d "$HOME/.codex/skills" ] && echo " ~/.codex/skills/gstack*"
[ -d "$HOME/.factory/skills" ] && echo " ~/.factory/skills/gstack*"
[ -d "$HOME/.kiro/skills" ] && echo " ~/.kiro/skills/gstack*"
[ -d "$HOME/.cursor/skills" ] && echo " ~/.cursor/skills/gstack*"
[ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR"
if [ -n "$_GIT_ROOT" ]; then
@@ -73,6 +76,7 @@ if [ "$FORCE" -eq 0 ]; then
[ -d "$_GIT_ROOT/.gstack" ] && echo " $_GIT_ROOT/.gstack/ (browse state + reports)"
[ -d "$_GIT_ROOT/.gstack-worktrees" ] && echo " $_GIT_ROOT/.gstack-worktrees/"
[ -d "$_GIT_ROOT/.agents/skills" ] && echo " $_GIT_ROOT/.agents/skills/gstack*"
[ -d "$_GIT_ROOT/.cursor/skills" ] && echo " $_GIT_ROOT/.cursor/skills/gstack*"
fi
# Preview running daemons
@@ -130,16 +134,76 @@ fi
# ─── Remove global Claude skills ────────────────────────────
CLAUDE_SKILLS="$HOME/.claude/skills"
# Skill-name inventory (#2563 gate a): every name setup could have installed —
# each source skill's directory name, its frontmatter name, their gstack-
# prefixed variants, and the alias dirs. Built BEFORE the install root is
# removed. A real directory in ~/.claude/skills is only deletable when its
# name is in this inventory AND its SKILL.md carries the generated banner.
# The seed names below are the alias dirs setup's _install_alias_skill_md
# creates (setup: link_claude_root_skill_alias + the connect-chrome call
# sites) — keep in sync with setup if an alias is added or renamed there.
_INVENTORY=" _gstack-command connect-chrome gstack-connect-chrome "
if [ -d "$GSTACK_DIR" ]; then
for _SRC in "$GSTACK_DIR"/*/; do
[ -f "$_SRC/SKILL.md" ] || continue
_SRC_NAME="$(basename "$_SRC")"
_FM_NAME=$(grep -m1 '^name:' "$_SRC/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]' || true)
for _N in "$_SRC_NAME" "$_FM_NAME"; do
[ -n "$_N" ] || continue
case "$_INVENTORY" in *" $_N "*) ;; *) _INVENTORY="$_INVENTORY$_N gstack-$_N " ;; esac
done
done
fi
_in_skill_inventory() { case "$_INVENTORY" in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
_SKIPPED_DIRS=()
if [ -d "$CLAUDE_SKILLS/gstack" ] || [ -L "$CLAUDE_SKILLS/gstack" ]; then
# Remove per-skill symlinks that point into gstack/
for _LINK in "$CLAUDE_SKILLS"/*; do
[ -L "$_LINK" ] || continue
_NAME="$(basename "$_LINK")"
# Remove per-skill entries created by setup. Three install shapes exist:
# 1. symlink entry (oldest installs)
# 2. real dir + SYMLINKED SKILL.md (standard Unix install)
# 3. real dir + REAL-FILE SKILL.md (Windows copy install, #2563)
# Shape 3 was skipped entirely — gstack-uninstall exited 0 and reported
# success while leaving ~52 gstack-* directories behind on Windows.
for _ENTRY in "$CLAUDE_SKILLS"/*; do
_NAME="$(basename "$_ENTRY")"
[ "$_NAME" = "gstack" ] && continue
_TARGET="$(readlink "$_LINK" 2>/dev/null || true)"
case "$_TARGET" in
gstack/*|*/gstack/*) rm -f "$_LINK"; REMOVED+=("claude/$_NAME") ;;
esac
if [ -L "$_ENTRY" ]; then
_TARGET="$(readlink "$_ENTRY" 2>/dev/null || true)"
case "$_TARGET" in
gstack/*|*/gstack/*) rm -f "$_ENTRY"; REMOVED+=("claude/$_NAME") ;;
esac
elif [ -d "$_ENTRY" ] && { [ -f "$_ENTRY/SKILL.md" ] || [ -L "$_ENTRY/SKILL.md" ]; }; then
if [ -L "$_ENTRY/SKILL.md" ]; then
# Shape 2: provenance readable from the symlink target itself.
# Gate 1: the name must be in gstack's skill inventory (parity with
# shape 3). Gate 2: the target must contain "gstack" as an ANCHORED
# path segment (gstack/*|*/gstack/*, same pattern as shape 1) — a
# bare *gstack* substring match would wipe a user's own skill whose
# SKILL.md merely lives under e.g. ~/tools/gstack-fork/.
_TARGET="$(readlink "$_ENTRY/SKILL.md" 2>/dev/null || true)"
if _in_skill_inventory "$_NAME"; then
case "$_TARGET" in
gstack/*|*/gstack/*) rm -rf "$_ENTRY"; REMOVED+=("claude/$_NAME") ;;
*) _SKIPPED_DIRS+=("$_ENTRY") ;;
esac
else
_SKIPPED_DIRS+=("$_ENTRY")
fi
elif _in_skill_inventory "$_NAME" && grep -q '<!-- AUTO-GENERATED from' "$_ENTRY/SKILL.md" 2>/dev/null; then
# Shape 3: delete ONLY when BOTH gates pass (F8) — the name is in
# gstack's skill inventory AND the SKILL.md carries the existing
# generated banner. ENG-OV10: the banner IS the provenance marker —
# every pre-v1.67 copy already carries it; inventing a new marker
# would refuse to delete legitimate old installs, recreating #2563.
rm -rf "$_ENTRY"
REMOVED+=("claude/$_NAME")
else
# A real dir we cannot prove is gstack-managed (name collision with a
# user's own skill, or a hand-written SKILL.md): NEVER delete — list.
_SKIPPED_DIRS+=("$_ENTRY")
fi
fi
done
rm -rf "$CLAUDE_SKILLS/gstack"
@@ -191,6 +255,32 @@ if [ -d "$KIRO_SKILLS" ]; then
done
fi
# ─── Remove Cursor skills ───────────────────────────────────
# Cursor installs are rendered REAL directories, so a bare gstack* glob could
# sweep a user's own dir that merely starts with "gstack" (e.g.
# ~/.cursor/skills/gstack-fork-notes). Provenance gate: a real dir is only
# deleted when its SKILL.md carries the generated banner; anything else is
# listed, never deleted. Symlinks stay ungated — removing a link never
# destroys user content.
_cursor_item_is_gstack_managed() {
# Symlinks and plain files are safe to remove; real dirs need the banner.
if [ -L "$1" ] || [ ! -d "$1" ]; then return 0; fi
grep -q '<!-- AUTO-GENERATED from' "$1/SKILL.md" 2>/dev/null
}
CURSOR_SKILLS="$HOME/.cursor/skills"
if [ -d "$CURSOR_SKILLS" ]; then
for _ITEM in "$CURSOR_SKILLS"/gstack*; do
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
if _cursor_item_is_gstack_managed "$_ITEM"; then
rm -rf "$_ITEM"
REMOVED+=("cursor/$(basename "$_ITEM")")
else
_SKIPPED_DIRS+=("$_ITEM")
fi
done
fi
# ─── Remove per-project .agents/ sidecar ─────────────────────
if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.agents/skills" ]; then
for _ITEM in "$_GIT_ROOT/.agents/skills"/gstack*; do
@@ -215,6 +305,23 @@ if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.factory/skills" ]; then
rmdir "$_GIT_ROOT/.factory" 2>/dev/null || true
fi
# ─── Remove per-project .cursor/skills/gstack* ──────────────
# Never rmdir .cursor itself — Cursor IDE stores rules and other user config there.
# Same provenance gate as the global cursor block above.
if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.cursor/skills" ]; then
for _ITEM in "$_GIT_ROOT/.cursor/skills"/gstack*; do
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
if _cursor_item_is_gstack_managed "$_ITEM"; then
rm -rf "$_ITEM"
REMOVED+=("cursor/$(basename "$_ITEM")")
else
_SKIPPED_DIRS+=("$_ITEM")
fi
done
rmdir "$_GIT_ROOT/.cursor/skills" 2>/dev/null || true
fi
# ─── Remove per-project state ───────────────────────────────
if [ -n "$_GIT_ROOT" ]; then
if [ -d "$_GIT_ROOT/.gstack" ]; then
@@ -236,6 +343,10 @@ if [ -x "$SETTINGS_HOOK" ]; then
if "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral 2>/dev/null | grep -q "removed [1-9]"; then
REMOVED+=("plan-tune cathedral hooks")
fi
# Timeline Stop hook (#2553).
if "$SETTINGS_HOOK" remove-source --source gstack-timeline-stop 2>/dev/null | grep -q "removed [1-9]"; then
REMOVED+=("timeline Stop hook")
fi
fi
# ─── Remove global state ────────────────────────────────────
@@ -252,6 +363,16 @@ for _TMP in /tmp/gstack-latest-version /tmp/gstack-sketch-*.html /tmp/gstack-ske
fi
done
# ─── Skipped-entry report ───────────────────────────────────
# Everything any provenance gate refused to delete (Claude shapes 2/3,
# cursor real dirs) — listed once, at the end, so nothing is silent.
if [ ${#_SKIPPED_DIRS[@]} -gt 0 ]; then
echo "left in place (not provably gstack-managed — remove by hand if they are yours):" >&2
for _D in "${_SKIPPED_DIRS[@]}"; do
echo " $_D" >&2
done
fi
# ─── Summary ────────────────────────────────────────────────
if [ ${#REMOVED[@]} -gt 0 ]; then
echo "Removed: ${REMOVED[*]}"
+323 -47
View File
@@ -30,15 +30,35 @@
// DRIFT_STALE_PKG path: sync package.json.version to the current VERSION
// file. No bump. Validates the VERSION pattern first.
//
// Contract: classify NEVER writes. write/repair mutate VERSION + package.json
// only. No git mutation, no network. Mirrors gstack-next-version's reader/writer
// split so /ship composes them.
// Contract: classify NEVER writes. write/repair mutate VERSION + the manifest
// + npm lockfiles (package-lock.json / npm-shrinkwrap.json, when present)
// only. No git mutation, no network. Mirrors gstack-next-version's
// reader/writer split so /ship composes them.
//
// Manifest resolution (all three subcommands accept --package-json-path):
// --package-json-path <p> → .gstack/package-json-path → ./package.json
// A repo whose only Node package lives in a subdirectory (web/, app/,
// frontend/) has no ROOT package.json. The tool used to report
// pkgExists:false there and write VERSION alone, leaving the manifest to be
// bumped by hand — the drift this tool exists to prevent, in the one layout
// where it silently did nothing (#2531).
//
// npm semver (decision 11, v1.67 fix-wave plan): VERSION is the 4-digit
// MAJOR.MINOR.PATCH.MICRO source of truth; npm rejects a fourth component,
// so the manifest and its lockfiles carry the npm-valid 3-digit translation
// (1.67.0.0 → 1.67.0). classify judges drift against the translated form
// (accepting the pre-v1.67 1:1 mirror as in-sync until the next write).
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { extractVersion, isJsonVersionPath, npmVersion, setVersionInJson } from "../lib/version-source";
const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/;
// 3- or 4-digit (#2501). gstack's own VERSION stays 4-digit MAJOR.MINOR.PATCH.
// MICRO and stays the source of truth, but a repo whose pinned version source
// is a package.json holds plain 3-digit semver, and rejecting it here meant
// /ship could not write a version at all in such a repo. See lib/version-source.ts.
const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/;
const DEFAULT = "0.0.0.0";
type State = "FRESH" | "ALREADY_BUMPED" | "DRIFT_STALE_PKG" | "DRIFT_UNEXPECTED";
@@ -53,29 +73,109 @@ function argVal(args: string[], flag: string): string | undefined {
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
}
/** Resolve the VERSION file path: --version-path, else .gstack/version-path, else "VERSION". */
function resolveVersionPath(cwd: string, explicit?: string): string {
if (explicit) return join(cwd, explicit);
const pin = join(cwd, ".gstack", "version-path");
if (existsSync(pin)) {
const p = readFileSync(pin, "utf-8").trim();
if (p) return join(cwd, p);
/**
* Containment guard: `.gstack/version-path` and `.gstack/package-json-path`
* are repo-controlled content. Without this, a cloned repo pinning
* `../../victim.json` — or an in-repo symlink pointing outside — turns a
* routine bump into an arbitrary file overwrite outside the repository.
* Rejects absolute paths, lexical `..` escapes, and symlink escapes (the
* deepest EXISTING ancestor is realpath'd, so a not-yet-created VERSION
* file is still checked through its parent directory).
*/
function assertRepoContained(cwd: string, rel: string, source: string): void {
const root = realpathSync(cwd);
const abs = resolve(root, rel);
const lex = relative(root, abs);
if (isAbsolute(rel) || lex === "" || lex.startsWith("..") || isAbsolute(lex)) {
fail(`${source} ('${rel}') resolves outside the repository. Refusing to read or write it.`, 2);
}
let probe = abs;
while (!existsSync(probe)) {
const parent = dirname(probe);
if (parent === probe) break;
probe = parent;
}
let real: string;
try {
real = realpathSync(probe);
} catch {
return; // vanished between existsSync and realpath — the read/write will fail honestly on its own
}
if (real !== root && !real.startsWith(root + sep)) {
fail(`${source} ('${rel}') resolves through a symlink to outside the repository. Refusing to read or write it.`, 2);
}
return join(cwd, "VERSION");
}
function readVersionFile(p: string): string {
/**
* Resolve the version file's path RELATIVE to the repo root: --version-path,
* else .gstack/version-path, else "VERSION".
*
* The relative form is what matters — `git show origin/<base>:<rel>` needs it
* (an absolute path is unusable there). Callers used to
* derive versionRel from the CLI flag alone (#2462), so a repo using the
* .gstack/version-path pin had its local (pinned) version compared against
* the BASE's root VERSION file: two different files. On a repo with no root
* VERSION the base then always read 0.0.0.0, making every branch look FRESH —
* and the pinned-JSON handling never engaged without the explicit flag.
* Resolving once, here, keeps base and current reads in step.
*/
function resolveVersionRel(cwd: string, explicit?: string): string {
if (explicit) {
const rel = explicit.trim();
assertRepoContained(cwd, rel, "--version-path");
return rel;
}
const pin = join(cwd, ".gstack", "version-path");
if (existsSync(pin)) {
const p = readFileSync(pin, "utf-8").split("\n")[0]?.trim() ?? "";
if (p) {
assertRepoContained(cwd, p, ".gstack/version-path");
return p;
}
}
return "VERSION";
}
function readVersionFile(p: string, versionRel = "VERSION"): string {
try {
const v = readFileSync(p, "utf-8").replace(/[\r\n\s]/g, "");
// extractVersion (#2501): a .json version-path is read as JSON (.version),
// not whitespace-stripped raw text that turns a package.json into garbage.
const v = extractVersion(readFileSync(p, "utf-8"), versionRel);
return v || DEFAULT;
} catch {
return DEFAULT;
}
}
/**
* Resolve the manifest path: --package-json-path, else
* .gstack/package-json-path, else "package.json" (#2531, mirrors
* resolveVersionRel). A repo whose only Node package lives in a
* subdirectory (web/, app/, frontend/) has no ROOT package.json, so the
* old join(cwd, "package.json") reported pkgExists:false and every bump
* silently wrote VERSION alone — leaving the manifest to be edited by
* hand, which is exactly the drift this tool exists to prevent.
*/
function resolvePkgPath(cwd: string, explicit?: string): string {
if (explicit) {
const rel = explicit.trim();
assertRepoContained(cwd, rel, "--package-json-path");
return join(cwd, rel);
}
const pin = join(cwd, ".gstack", "package-json-path");
if (existsSync(pin)) {
const p = readFileSync(pin, "utf-8").split("\n")[0]?.trim() ?? "";
if (p) {
assertRepoContained(cwd, p, ".gstack/package-json-path");
return join(cwd, p);
}
}
return join(cwd, "package.json");
}
/** package.json version + existence, parsed without spawning node. */
function readPkgVersion(cwd: string): { exists: boolean; version: string } {
const pkgPath = join(cwd, "package.json");
function readPkgVersion(pkgPath: string): { exists: boolean; version: string } {
if (!existsSync(pkgPath)) return { exists: false, version: "" };
let raw: string;
try {
@@ -87,20 +187,65 @@ function readPkgVersion(cwd: string): { exists: boolean; version: string } {
try {
parsed = JSON.parse(raw);
} catch {
fail("package.json is not valid JSON. Fix the file before re-running /ship.", 2);
fail(`${pkgPath} is not valid JSON. Fix the file before re-running /ship.`, 2);
}
const version = (parsed as { version?: unknown })?.version;
return { exists: true, version: typeof version === "string" ? version : "" };
}
function writePkgVersion(cwd: string, version: string): void {
const pkgPath = join(cwd, "package.json");
function writePkgVersion(pkgPath: string, version: string): void {
const raw = readFileSync(pkgPath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
parsed.version = version;
writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + "\n");
}
/**
* npm records the package version twice in its lockfiles — top-level
* `version` and, in lockfileVersion >= 2, `packages[""].version` (the entry
* describing the root package itself) — and `npm install` keeps both in
* step. Nothing else in a release does, so a lockfile left behind drifts one
* field per bump until someone runs npm, dirtying the tree on the next
* `npm install` far from the cause (#2567). Pure JSON edit: no npm spawn,
* no dependency-tree churn.
*
* Synced ONLY when the file already exists — never created (gstack itself
* is bun-only; decision pinned in the v1.67 fix-wave plan).
* npm-shrinkwrap.json shares the format and, when present, is what npm
* actually honors, so both names are covered. Returns the names synced.
*/
const NPM_LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json"];
function syncNpmLockfiles(dir: string, version: string, root: string): string[] {
const synced: string[] = [];
for (const name of NPM_LOCKFILES) {
const lockPath = join(dir, name);
if (!existsSync(lockPath)) continue;
// A lockfile that is a symlink out of the repo would make this write an
// arbitrary-file overwrite (same class as the version-path pin escape).
// Skip with a warning — unlike the pins, a weird lockfile shouldn't
// brick the whole bump.
try {
const realRoot = realpathSync(root);
const realLock = realpathSync(lockPath);
if (realLock !== realRoot && !realLock.startsWith(realRoot + sep)) {
process.stderr.write(`WARNING: ${name} resolves outside the repository (symlink); not synced.\n`);
continue;
}
} catch {
continue;
}
const parsed = JSON.parse(readFileSync(lockPath, "utf-8")) as Record<string, unknown>;
parsed.version = version;
const packages = parsed.packages as Record<string, Record<string, unknown>> | undefined;
if (packages && typeof packages[""] === "object" && packages[""] !== null) {
packages[""].version = version;
}
writeFileSync(lockPath, JSON.stringify(parsed, null, 2) + "\n");
synced.push(name);
}
return synced;
}
function baseVersion(cwd: string, base: string, versionRel: string): string {
// Verify the base ref resolves, mirroring the Step 12 guard.
try {
@@ -110,35 +255,64 @@ function baseVersion(cwd: string, base: string, versionRel: string): string {
}
try {
const out = execFileSync("git", ["show", `origin/${base}:${versionRel}`], { cwd }).toString();
const v = out.replace(/[\r\n\s]/g, "");
return v || DEFAULT;
return extractVersion(out, versionRel) || DEFAULT;
} catch {
// VERSION absent on base (new repo / new file) → treat as 0.0.0.0.
return DEFAULT;
}
}
function classifyState(current: string, base: string, pkgExists: boolean, pkgVersion: string): State {
/**
* `expectedPkg` is what the manifest SHOULD hold for the current VERSION —
* the npm-valid 3-digit translation (decision 11: npm rejects a fourth
* component, so a correctly-synced `1.67.0` must not read as drift against
* `1.67.0.0` forever). The historical 1:1 mirror (pre-v1.67 installs whose
* package.json still carries the 4-digit form) is also accepted as in-sync;
* write/repair migrate those to the translated form on the next release.
*/
function classifyState(
current: string,
base: string,
pkgExists: boolean,
pkgVersion: string,
expectedPkg: string = current,
): State {
const pkgAgrees =
!pkgExists || !pkgVersion || pkgVersion === expectedPkg || pkgVersion === current;
if (current === base) {
// VERSION unchanged vs base. A diverging package.json means someone hand-edited
// package.json bypassing /ship — unsafe to guess which is authoritative.
if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_UNEXPECTED";
if (!pkgAgrees) return "DRIFT_UNEXPECTED";
return "FRESH";
}
// VERSION already moved past base.
if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_STALE_PKG";
if (!pkgAgrees) return "DRIFT_STALE_PKG";
return "ALREADY_BUMPED";
}
function cmdClassify(args: string[], cwd: string): void {
const base = argVal(args, "--base");
if (!base) fail("classify requires --base <branch>", 2);
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
const versionRel = argVal(args, "--version-path") ?? "VERSION";
const current = readVersionFile(versionPath);
const versionRel = resolveVersionRel(cwd, argVal(args, "--version-path"));
const versionPath = join(cwd, versionRel);
const current = readVersionFile(versionPath, versionRel);
const baseV = baseVersion(cwd, base!, versionRel);
const pkg = readPkgVersion(cwd);
const state = classifyState(current, baseV, pkg.exists, pkg.version);
// When the version-path IS a package.json (#2501), that file is the single
// source of truth and the "VERSION vs package.json" drift states cannot
// arise — they are the same file. Reporting it as its own pkg keeps DRIFT_*
// out of the classification instead of inventing a disagreement between a
// file and itself.
const jsonSource = isJsonVersionPath(versionRel);
const pkgPath = jsonSource ? versionPath : resolvePkgPath(cwd, argVal(args, "--package-json-path"));
const pkg = jsonSource
? { exists: existsSync(versionPath), version: current === DEFAULT ? "" : current }
: readPkgVersion(pkgPath);
// Decision 11: the manifest carries the npm-valid 3-digit translation of
// the 4-digit VERSION; drift is judged against the translated form. A
// JSON version-path is its own source of truth, so its expected form is
// the version itself.
const expectedPkg = jsonSource ? current : npmVersion(current);
const state = classifyState(current, baseV, pkg.exists, pkg.version, expectedPkg);
process.stdout.write(
JSON.stringify({
state,
@@ -146,6 +320,8 @@ function cmdClassify(args: string[], cwd: string): void {
currentVersion: current,
pkgVersion: pkg.version || null,
pkgExists: pkg.exists,
pkgPath: pkg.exists ? relative(cwd, pkgPath) : null,
expectedPkgVersion: pkg.exists ? expectedPkg : null,
}) + "\n",
);
// DRIFT_UNEXPECTED is a real, decidable state — the caller stops on it, but the
@@ -157,43 +333,143 @@ function cmdWrite(args: string[], cwd: string): void {
const version = argVal(args, "--version");
if (!version) fail("write requires --version <X.Y.Z.W>", 2);
if (!VERSION_RE.test(version!)) {
fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH.MICRO. Aborting.`, 2);
fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH[.MICRO]. Aborting.`, 2);
}
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
writeFileSync(versionPath, version + "\n");
if (existsSync(join(cwd, "package.json"))) {
const versionRel = resolveVersionRel(cwd, argVal(args, "--version-path"));
const versionPath = join(cwd, versionRel);
// A package.json version-path (#2501) is written in place, keeping the rest
// of the file intact — and it is the ONLY file written. Also syncing a root
// package.json here would be a guess about which of two JSON files the repo
// actually publishes from; in a monorepo whose truth is frontend/package.json
// the root one either doesn't exist or isn't the version users see.
if (isJsonVersionPath(versionRel)) {
if (!existsSync(versionPath)) {
fail(`write: ${versionRel} does not exist. Check --version-path / .gstack/version-path.`, 2);
}
// Decision 11: a JSON manifest can only carry npm-valid semver. A repo
// whose package.json still mirrors the legacy 4-digit form and pins it as
// the version-path would otherwise get "1.67.0.1" written into a manifest
// npm rejects forever — with no drift state to catch it (a JSON source is
// self-consistent by construction).
const jsonV = npmVersion(version!);
let manifestWritten = false;
let lockSynced: string[] = [];
try {
writePkgVersion(cwd, version!);
writeFileSync(versionPath, setVersionInJson(readFileSync(versionPath, "utf-8"), jsonV));
manifestWritten = true;
// The pinned manifest's OWN lockfiles (beside it) stay in step too.
lockSynced = syncNpmLockfiles(dirname(versionPath), jsonV, cwd);
} catch {
fail(
"failed to update package.json. VERSION was written but package.json is now stale. " +
"Re-run — classify will report DRIFT_STALE_PKG and repair will sync it.",
manifestWritten
? `write: ${versionRel} was updated but its npm lockfiles were not (corrupt lockfile?). ` +
"Fix or delete the lockfile beside it, then re-run write with the same --version."
: `write: failed to update ${versionRel} (is it valid JSON?).`,
3,
);
}
if (jsonV !== version) {
process.stderr.write(
`write: ${versionRel} carries the npm-valid translation ${jsonV} (a JSON manifest cannot hold 4-digit ${version}). ` +
"Consecutive MICRO releases translate to the SAME manifest version — pin a plain VERSION file if that matters.\n",
);
}
process.stdout.write(
JSON.stringify({
wrote: jsonV,
// Only surfaced when a 4-digit request was translated (the healthy
// 3-digit-pinned path is an identity write).
...(jsonV !== version ? { requestedVersion: version } : {}),
versionPath: versionRel,
packageJson: true,
packageLock: lockSynced.length > 0,
}) + "\n",
);
return;
}
const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path"));
const hasPkg = existsSync(pkgPath);
writeFileSync(versionPath, version + "\n");
let lockSynced: string[] = [];
// Decision 11: the manifest (and its lockfiles) carry the npm-valid
// 3-digit translation — npm rejects a fourth component, so mirroring the
// raw 4-digit form breaks `npm ci` in any repo npm actually manages.
// VERSION keeps the full 4-digit form; it stays the source of truth.
const manifestV = npmVersion(version!);
if (hasPkg) {
let pkgWritten = false;
try {
writePkgVersion(pkgPath, manifestV);
pkgWritten = true;
lockSynced = syncNpmLockfiles(dirname(pkgPath), manifestV, cwd);
} catch {
// Accurate recovery per failure point: classify only reads
// package.json (never lockfiles), so "re-run and repair" is only true
// when package.json itself is the stale file.
fail(
pkgWritten
? `VERSION and ${relative(cwd, pkgPath)} were written but the npm lockfiles were not ` +
"(corrupt lockfile?). classify cannot see lockfile drift — fix or delete the lockfile, " +
"then re-run write with the same --version."
: `failed to update ${relative(cwd, pkgPath)}. VERSION was written but package.json is now ` +
"stale. Re-run — classify will report DRIFT_STALE_PKG and repair will sync it.",
3,
);
}
}
process.stdout.write(JSON.stringify({ wrote: version, packageJson: existsSync(join(cwd, "package.json")) }) + "\n");
process.stdout.write(
JSON.stringify({
wrote: version,
packageJson: hasPkg,
packageJsonPath: hasPkg ? relative(cwd, pkgPath) : null,
packageJsonVersion: hasPkg ? manifestV : null,
packageLock: lockSynced.length > 0,
}) + "\n",
);
}
function cmdRepair(args: string[], cwd: string): void {
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
const current = readVersionFile(versionPath);
const versionRel = resolveVersionRel(cwd, argVal(args, "--version-path"));
const versionPath = join(cwd, versionRel);
// Nothing to repair when the version lives in a package.json (#2501): there
// is no second file to drift from, and classify never reports DRIFT_* for
// that shape.
if (isJsonVersionPath(versionRel)) {
process.stdout.write(
JSON.stringify({ repaired: null, reason: `${versionRel} is the single source of truth; no drift possible` }) + "\n",
);
return;
}
const current = readVersionFile(versionPath, versionRel);
if (!VERSION_RE.test(current)) {
fail(
`VERSION file contents (${current}) do not match MAJOR.MINOR.PATCH.MICRO. ` +
`VERSION file contents (${current}) do not match MAJOR.MINOR.PATCH[.MICRO]. ` +
"Refusing to propagate invalid semver into package.json. Fix VERSION, then re-run /ship.",
2,
);
}
if (!existsSync(join(cwd, "package.json"))) {
fail("repair: no package.json to sync.", 2);
const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path"));
if (!existsSync(pkgPath)) {
fail(`repair: no package.json to sync (looked at ${relative(cwd, pkgPath)}).`, 2);
}
// Decision 11: repair syncs the manifest + lockfiles to the npm-valid
// 3-digit translation of the current VERSION.
const manifestV = npmVersion(current);
try {
writePkgVersion(cwd, current);
writePkgVersion(pkgPath, manifestV);
syncNpmLockfiles(dirname(pkgPath), manifestV, cwd);
} catch {
fail("drift repair failed — could not update package.json.", 3);
fail("drift repair failed — could not update package.json/npm lockfiles.", 3);
}
process.stdout.write(JSON.stringify({ repaired: current }) + "\n");
process.stdout.write(
JSON.stringify({
repaired: current,
packageJsonPath: relative(cwd, pkgPath),
packageJsonVersion: manifestV,
}) + "\n",
);
}
// Exported for unit tests (pure logic, no I/O).