mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-15 17:35:29 +02:00
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user