fix(slug): give nested-group projects their own state bucket and verify checkpoint identity (#3003, #2901)

gstack-slug kept only the last two remote path segments, so
customer-a/product/repo and customer-b/product/repo shared one bucket
and /context-restore could present another project's checkpoint.

- One canonical-remote rule (bash + TS, golden vectors) used by all six
  resolvers: lowercased host, no userinfo/port/.git, Azure DevOps and
  Bitbucket Server normalized. 3+ segment remotes get
  <last-two>-<16 hex>; two-segment remotes (all of GitHub) are unchanged.
- Versioned sticky cache (v2:) keeps cache hits git-free.
- context-save stamps the credential-free remote and root;
  context-restore only treats this project's checkpoints as latest,
  reports foreign/unverified ones first, and treats a different root
  with the same remote (Conductor handoff) as information.
- gstack-slug --adopt-legacy copies only files the user confirms.
- question-preference-hook reads the same versioned cache, so never-ask
  preferences fire; unknown identity asks instead of auto-deciding.
This commit is contained in:
garrytan committed 2026-10-02 14:49:15 +00:00
1 parent bc8bec4df2
commit 63a6cbb8ee
34 files changed
+1745 -130

No files matched your search

+6 -27
View File
@@ -13,6 +13,7 @@ import { existsSync, readdirSync, statSync, readFileSync, openSync, readSync, cl
import { join, basename } from "path";
import { execSync } from "child_process";
import { homedir } from "os";
import { canonicalRemote } from "../lib/remote-identity";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -122,33 +123,11 @@ function windowToDate(window: string): Date {
// ── URL normalization ──────────────────────────────────────────────────────
export function normalizeRemoteUrl(url: string): string {
let normalized = url.trim();
// SSH → HTTPS: git@github.com:user/repo → https://github.com/user/repo
const sshMatch = normalized.match(/^(?:ssh:\/\/)?git@([^:]+):(.+)$/);
if (sshMatch) {
normalized = `https://${sshMatch[1]}/${sshMatch[2]}`;
}
// Strip .git suffix
if (normalized.endsWith(".git")) {
normalized = normalized.slice(0, -4);
}
// Lowercase the host portion
try {
const parsed = new URL(normalized);
parsed.hostname = parsed.hostname.toLowerCase();
normalized = parsed.toString();
// Remove trailing slash
if (normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
} catch {
// Not a valid URL (e.g., local:<path>), return as-is
}
return normalized;
// One identity per repository whichever way it was cloned (ssh, ssh:// with
// a port, https with credentials): the shared canonical remote from
// lib/remote-identity.ts, rendered as an https URL for display.
const id = canonicalRemote(url.trim());
return id.hosted ? `https://${id.canonical}` : url.trim();
}
// ── Git helpers ────────────────────────────────────────────────────────────
+147
View File
@@ -0,0 +1,147 @@
# shellcheck shell=bash
# gstack-remote-identity.sh — the one bash owner of git-remote project identity
# (twin of lib/remote-identity.ts; test/remote-identity-vectors.test.ts runs both
# against the golden vectors in test/fixtures/remote-identity-vectors.json, so
# the two can never disagree on a canonical remote or a slug). Sourced, never
# executed: bin/gstack-slug and browse/bin/remote-slug source it. bash 3.2 safe.
#
# Canonical remote (#3003): one spelling per repository, whichever way it was
# cloned. Hosted remotes (scheme://… or scp-like host:path) become
# "<host>/<seg>/<seg>…": host lowercased (ASCII, locale-independent), userinfo,
# credentials and port dropped, one trailing ".git" and stray slashes removed,
# then host-specific normalization BEFORE segments are counted:
# - Azure DevOps: ssh "v3/" prefix and https "_git" dropped, ssh.dev.azure.com
# and *.visualstudio.com folded to dev.azure.com/<org>/<project>/<repo>.
# - Bitbucket Server: a leading "scm/" (https only spelling) dropped.
# Local remotes (absolute/relative paths, file://) keep their path as identity.
#
# Slug: a hosted remote with 3+ segments (GitLab nested groups, Azure DevOps)
# files under "<last-two>-<first 16 hex of sha256(canonical)>" so a/product/repo
# and b/product/repo stop sharing projects/product-repo/. Everything else (all
# 2-segment remotes, i.e. all of GitHub, and local paths) keeps the legacy
# last-two parse byte-for-byte, so no existing bucket moves.
# _gri_lower — ASCII lowercase, locale-independent.
_gri_lower() { printf '%s' "$1" | LC_ALL=C tr 'A-Z' 'a-z'; }
# gstack_sha256_16 <string> — first 16 hex chars of sha256, portable.
gstack_sha256_16() {
_gri_digest=""
if command -v sha256sum >/dev/null 2>&1; then
_gri_digest=$(printf '%s' "$1" | sha256sum 2>/dev/null)
elif command -v shasum >/dev/null 2>&1; then
_gri_digest=$(printf '%s' "$1" | shasum -a 256 2>/dev/null)
elif command -v openssl >/dev/null 2>&1; then
_gri_digest=$(printf '%s' "$1" | openssl dgst -sha256 -r 2>/dev/null)
fi
_gri_digest=$(printf '%s' "$_gri_digest" | LC_ALL=C tr -cd '0-9a-f' | cut -c1-16)
printf '%s' "$_gri_digest"
}
# gstack_legacy_remote_slug <url> — the pre-#3003 parse, kept byte-identical:
# strip ONE trailing ".git", keep the last two path segments as owner-repo
# (sed's no-match passthrough leaves the stripped URL itself), sanitize, and
# reject degenerate results ("", ".", "..").
gstack_legacy_remote_slug() {
_gri_l=$(printf '%s' "${1%.git}" | sed -E 's#.*[:/]([^/]+)/([^/]+)$#\1-\2#' | tr -cd 'a-zA-Z0-9._-')
case "$_gri_l" in ""|.|..) _gri_l="" ;; esac
printf '%s' "$_gri_l"
}
# gstack_canonical_remote <url> — sets:
# _gri_canon canonical identity string ("" when the url is empty)
# _gri_hosted 1 for host-bearing remotes, 0 for local paths
# _gri_nseg number of path segments after normalization (hosted only)
# _gri_last2 "<second-to-last>-<last>" segment pair (hosted, nseg >= 2)
gstack_canonical_remote() {
_gri_url=$(printf '%s' "$1" | tr -d '\r\n')
_gri_canon=""; _gri_hosted=0; _gri_nseg=0; _gri_last2=""
[ -n "$_gri_url" ] || return 0
_gri_host=""; _gri_path=""; _gri_scheme=""
if [[ "$_gri_url" =~ ^([A-Za-z][A-Za-z0-9+.-]*)://(.*)$ ]]; then
_gri_scheme=$(_gri_lower "${BASH_REMATCH[1]}")
_gri_rest="${BASH_REMATCH[2]}"
if [ "$_gri_scheme" != "file" ]; then
_gri_auth="${_gri_rest%%/*}"
if [ "$_gri_auth" = "$_gri_rest" ]; then _gri_path=""; else _gri_path="${_gri_rest#*/}"; fi
_gri_auth="${_gri_auth##*@}"
if [[ "$_gri_auth" =~ ^(\[[^]]*\]) ]]; then
_gri_host="${BASH_REMATCH[1]}"
else
_gri_host="${_gri_auth%%:*}"
fi
_gri_hosted=1
else
_gri_path="$_gri_rest"
fi
elif ! [[ "$_gri_url" =~ ^[A-Za-z]:([/\\]|$) ]] && [[ "$_gri_url" =~ ^([^/:]+):(.*)$ ]]; then
_gri_host="${BASH_REMATCH[1]##*@}"
_gri_path="${BASH_REMATCH[2]}"
_gri_hosted=1
else
_gri_path="$_gri_url"
fi
if [ "$_gri_hosted" -eq 0 ]; then
while [ "${#_gri_path}" -gt 1 ] && [ "${_gri_path%/}" != "$_gri_path" ]; do _gri_path="${_gri_path%/}"; done
_gri_path="${_gri_path%.git}"
while [ "${#_gri_path}" -gt 1 ] && [ "${_gri_path%/}" != "$_gri_path" ]; do _gri_path="${_gri_path%/}"; done
_gri_canon="$_gri_path"
return 0
fi
_gri_host=$(_gri_lower "$_gri_host")
while [ "${_gri_path%/}" != "$_gri_path" ]; do _gri_path="${_gri_path%/}"; done
_gri_path="${_gri_path%.git}"
_gri_segs=()
_gri_rem="$_gri_path"
while [ -n "$_gri_rem" ]; do
_gri_s="${_gri_rem%%/*}"
if [ "$_gri_s" = "$_gri_rem" ]; then _gri_rem=""; else _gri_rem="${_gri_rem#*/}"; fi
if [ -n "$_gri_s" ]; then _gri_segs[${#_gri_segs[@]}]="$_gri_s"; fi
done
case "$_gri_host" in
ssh.dev.azure.com|vs-ssh.visualstudio.com)
if [ "${#_gri_segs[@]}" -gt 0 ] && [ "${_gri_segs[0]}" = "v3" ]; then _gri_segs=("${_gri_segs[@]:1}"); fi
_gri_host="dev.azure.com" ;;
dev.azure.com) ;;
*.visualstudio.com)
if [ "${#_gri_segs[@]}" -gt 0 ] && [ "${_gri_segs[0]}" = "DefaultCollection" ]; then _gri_segs=("${_gri_segs[@]:1}"); fi
_gri_segs=("${_gri_host%%.*}" ${_gri_segs[@]+"${_gri_segs[@]}"})
_gri_host="dev.azure.com" ;;
*)
if [ "${#_gri_segs[@]}" -ge 3 ] && [ "${_gri_segs[0]}" = "scm" ]; then _gri_segs=("${_gri_segs[@]:1}"); fi ;;
esac
if [ "$_gri_host" = "dev.azure.com" ]; then
_gri_kept=()
for _gri_s in ${_gri_segs[@]+"${_gri_segs[@]}"}; do
[ "$_gri_s" = "_git" ] || _gri_kept[${#_gri_kept[@]}]="$_gri_s"
done
_gri_segs=(${_gri_kept[@]+"${_gri_kept[@]}"})
fi
_gri_nseg=${#_gri_segs[@]}
_gri_canon="$_gri_host"
for _gri_s in ${_gri_segs[@]+"${_gri_segs[@]}"}; do _gri_canon="$_gri_canon/$_gri_s"; done
if [ "$_gri_nseg" -ge 2 ]; then
_gri_last2="${_gri_segs[$((_gri_nseg - 2))]}-${_gri_segs[$((_gri_nseg - 1))]}"
fi
return 0
}
# gstack_remote_slug <url> — sets _gri_slug (the project slug for this remote,
# "" when degenerate) and _gri_legacy_slug (the pre-#3003 slug). The two differ
# only for hosted remotes with 3+ segments.
gstack_remote_slug() {
gstack_canonical_remote "$1"
_gri_legacy_slug=$(gstack_legacy_remote_slug "$1")
_gri_slug="$_gri_legacy_slug"
if [ "$_gri_hosted" -eq 1 ] && [ "$_gri_nseg" -ge 3 ]; then
_gri_hash=$(gstack_sha256_16 "$_gri_canon")
if [ "${#_gri_hash}" -eq 16 ]; then
_gri_slug=$(printf '%s-%s' "$_gri_last2" "$_gri_hash" | tr -cd 'a-zA-Z0-9._-')
fi
fi
return 0
}
+271 -10
View File
@@ -2,6 +2,27 @@
# gstack-slug — output project slug and sanitized branch name
# Usage: eval "$(gstack-slug)" → sets SLUG and BRANCH variables
# Or: gstack-slug → prints SLUG=... and BRANCH=... lines
# Modes (not on the hot path; see `gstack-slug --help`):
# --identity also print PROJECT_REMOTE, PROJECT_ROOT, LEGACY_SLUG
# --stamp-checkpoint <file> write remote:/project_root: into a checkpoint's frontmatter
# --classify-checkpoints read checkpoint paths on stdin, print "<status>\t<path>"
# --adopt-legacy [--copy <relpath>...] review/copy files from the pre-#3003 bucket
#
# Nested-group remotes (#3003): a hosted remote with 3+ path segments
# (gitlab.com/a/product/repo) files under "<last-two>-<16 hex>" instead of the
# last two segments alone, so projects in different groups stop sharing one
# bucket. 2-segment remotes (all of GitHub) and local paths are unchanged. The
# canonical-remote and slug rules live in bin/gstack-remote-identity.sh (twin of
# lib/remote-identity.ts). Earlier data stays in the old bucket until the user
# copies it with --adopt-legacy; nothing moves automatically. ssh host aliases
# (git@work-gitlab:…) are a different canonical host than the https spelling;
# pin those with GSTACK_PROJECT_SLUG.
#
# Cache format: entries are versioned ("v2:<slug>"). An unversioned entry from
# an older gstack is recomputed once (one git spawn) and rewritten as v2; it
# keeps its value unless it is exactly the pre-#3003 last-two slug of a
# 3+-segment remote, so #2212 sticky identity survives the upgrade and later
# cache hits stay git-free.
#
# Resolution order (highest precedence first):
# 0. $GSTACK_PROJECT_SLUG env override (documented escape hatch)
@@ -52,6 +73,42 @@ set -euo pipefail
# home (observed: 2,528 stale temp-cwd entries accumulated in ~/.gstack).
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
. "$(dirname "$0")/gstack-remote-identity.sh" 2>/dev/null || { echo "$0: $(dirname "$0")/gstack-remote-identity.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; }
MODE=default
MODE_ARG=""
COPY_PATHS=()
case "${1:-}" in
"") ;;
--identity) MODE=identity ;;
--stamp-checkpoint) MODE=stamp; MODE_ARG="${2:-}"
[[ -n "$MODE_ARG" ]] || { echo "usage: gstack-slug --stamp-checkpoint <file>" >&2; exit 2; } ;;
--classify-checkpoints) MODE=classify ;;
--adopt-legacy) MODE=adopt; shift
if [[ "${1:-}" == "--copy" ]]; then
shift
[[ $# -gt 0 ]] || { echo "usage: gstack-slug --adopt-legacy --copy <relpath>..." >&2; exit 2; }
COPY_PATHS=("$@")
elif [[ -n "${1:-}" ]]; then
echo "usage: gstack-slug --adopt-legacy [--copy <relpath>...]" >&2; exit 2
fi ;;
-h|--help)
cat <<'USAGE'
gstack-slug — print SLUG=<project slug> and BRANCH=<branch> for the current directory.
eval "$(gstack-slug)" set SLUG and BRANCH
gstack-slug --identity also print PROJECT_REMOTE (canonical, credential-free),
PROJECT_ROOT and LEGACY_SLUG (pre-#3003 bucket, if different)
gstack-slug --stamp-checkpoint FILE write remote:/project_root: into FILE's frontmatter
gstack-slug --classify-checkpoints read checkpoint paths on stdin; print "<status><TAB><path>",
status = match | match-root | foreign | unstamped
gstack-slug --adopt-legacy list the old bucket's files (checkpoints marked
matching/foreign/unknown); asks per file on a terminal
gstack-slug --adopt-legacy --copy P… copy the named files (paths relative to the old bucket)
Env: GSTACK_PROJECT_SLUG pins the slug (ssh host aliases, vendored sub-repos); never cached.
USAGE
exit 0 ;;
*) echo "gstack-slug: unknown argument: $1 (try --help)" >&2; exit 2 ;;
esac
CACHE_DIR="$GSTACK_STATE_ROOT/slug-cache"
PROJECT_DIR="$(pwd)"
# Encode absolute path as cache key: /Users/j/foo → _Users_j_foo
@@ -65,6 +122,8 @@ SLUG=""
SLUG_FROM_ENV=0
if [[ -n "${GSTACK_PROJECT_SLUG:-}" ]]; then
SLUG=$(printf '%s' "$GSTACK_PROJECT_SLUG" | tr -cd 'a-zA-Z0-9._-')
# A pin of "." or ".." would file state outside projects/; ignore it.
case "$SLUG" in .|..) SLUG="" ;; esac
# 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
@@ -200,8 +259,15 @@ _resolve_remote() {
# Legit remote-adopting stickiness is safe too: there the repo that
# adopted the remote IS the marker root (REMOTE_ROOT == PROJECT_ROOT),
# so the heal never fires.
_CACHE_V2=0
LEGACY_NOTICE_SLUG=""
if [[ -z "$SLUG" && -f "$CACHE_FILE" ]]; then
_CACHED=$(cat "$CACHE_FILE" 2>/dev/null | tr -cd 'a-zA-Z0-9._-')
_RAW_CACHE=$(cat "$CACHE_FILE" 2>/dev/null || true)
if [[ "$_RAW_CACHE" == v2:* ]]; then
_CACHE_V2=1
_RAW_CACHE="${_RAW_CACHE#v2:}"
fi
_CACHED=$(printf '%s' "$_RAW_CACHE" | tr -cd 'a-zA-Z0-9._-')
if [[ -n "$_CACHED" ]]; then
_PWD_BASE=$(basename "$PROJECT_DIR" | tr -cd 'a-zA-Z0-9._-')
_ROOT_BASE=""
@@ -214,23 +280,39 @@ if [[ -z "$SLUG" && -f "$CACHE_FILE" ]]; then
&& ! git -C "$PROJECT_ROOT" remote get-url origin >/dev/null 2>&1 \
&& { _resolve_remote; [[ -n "$REMOTE_URL" && "$REMOTE_ROOT" != "$PROJECT_ROOT" ]]; }; then
: # degraded-ancestor (stray-repo) shape — recompute below and self-heal the cache
else
elif [[ "$_CACHE_V2" -eq 1 ]]; then
SLUG="$_CACHED"
else
# Unversioned (pre-#3003) entry: recompute once. Keep the sticky value
# unless it is exactly the old last-two slug of a 3+-segment remote.
SLUG="$_CACHED"
_resolve_remote
if [[ -n "$REMOTE_URL" ]]; then
gstack_remote_slug "$REMOTE_URL"
if [[ -n "$_gri_slug" && "$_gri_slug" != "$_gri_legacy_slug" && "$_CACHED" == "$_gri_legacy_slug" ]]; then
SLUG="$_gri_slug"
LEGACY_NOTICE_SLUG="$_gri_legacy_slug"
fi
fi
fi
fi
fi
# 2. Canonical remote-derived slug. Sourced from the outermost remote-bearing
# repo (see 1a) — NOT from PROJECT_ROOT, which may be a marker-only
# ancestor with no remote. Parse kept byte-identical to
# browse/bin/remote-slug (strip trailing .git, then owner/repo → owner-repo)
# ancestor with no remote. The slug rule (legacy owner-repo for 2-segment
# and local remotes, "<last-two>-<16 hex>" for 3+-segment hosted remotes)
# is shared with browse/bin/remote-slug through gstack-remote-identity.sh,
# so the two bins agree on every canonical-remote repo, worktrees included.
# Parity pinned by test/gstack-slug-parity.test.ts.
if [[ -z "$SLUG" ]]; then
_resolve_remote
if [[ -n "$REMOTE_URL" ]]; then
RAW_SLUG=$(printf '%s' "${REMOTE_URL%.git}" | sed -E 's#.*[:/]([^/]+)/([^/]+)$#\1-\2#')
SLUG=$(printf '%s' "$RAW_SLUG" | tr -cd 'a-zA-Z0-9._-')
gstack_remote_slug "$REMOTE_URL"
SLUG="$_gri_slug"
if [[ -n "$SLUG" && "$_gri_slug" != "$_gri_legacy_slug" ]]; then
LEGACY_NOTICE_SLUG="$_gri_legacy_slug"
fi
# Dot-only / degenerate guard: a hostile origin like `url = ..` (git
# accepts it) passes sed unchanged and would become SLUG=".." — filing
# state one level ABOVE ~/.gstack/projects/. Reject empty/"."/".."/
@@ -264,17 +346,196 @@ if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 ]]; then
if [[ -f "$CACHE_FILE" ]]; then
CURRENT_CACHE=$(cat "$CACHE_FILE" 2>/dev/null || true)
fi
if [[ "$CURRENT_CACHE" != "$SLUG" ]]; then
if [[ "$CURRENT_CACHE" != "v2:$SLUG" ]]; then
mkdir -p "$CACHE_DIR" 2>/dev/null || true
CACHE_TMP=$(mktemp "$CACHE_DIR/.slug-XXXXXX" 2>/dev/null) || CACHE_TMP=""
if [[ -n "$CACHE_TMP" ]]; then
printf '%s' "$SLUG" > "$CACHE_TMP" && mv "$CACHE_TMP" "$CACHE_FILE" 2>/dev/null || rm -f "$CACHE_TMP" 2>/dev/null
printf 'v2:%s' "$SLUG" > "$CACHE_TMP" && mv "$CACHE_TMP" "$CACHE_FILE" 2>/dev/null || rm -f "$CACHE_TMP" 2>/dev/null
fi
fi
fi
# One notice on the resolve that first files this project under its new
# nested-group slug while the old shared bucket still exists (stderr only, so
# eval consumers are unaffected; later runs are v2 cache hits and stay quiet).
if [[ -n "$LEGACY_NOTICE_SLUG" && "$SLUG" != "$LEGACY_NOTICE_SLUG" && -d "$GSTACK_STATE_ROOT/projects/$LEGACY_NOTICE_SLUG" ]]; then
echo "gstack now files this project under projects/$SLUG; earlier data is still in projects/$LEGACY_NOTICE_SLUG (review and copy it with: gstack-slug --adopt-legacy)" >&2
fi
RAW_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || RAW_BRANCH=""
BRANCH=$(printf '%s' "${RAW_BRANCH:-}" | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')
BRANCH="${BRANCH:-unknown}"
echo "SLUG=$SLUG"
echo "BRANCH=$BRANCH"
if [[ "$MODE" == "default" ]]; then
echo "SLUG=$SLUG"
echo "BRANCH=$BRANCH"
exit 0
fi
# ── Project identity (#3003) ─────────────────────────────────────────────────
# Identity = canonical remote; the root is identity only when there is no
# remote. Roots are physical (pwd -P) so a symlinked spelling compares equal.
ID_REMOTE=""
ID_ROOT=""
ID_LEGACY=""
_resolve_identity() {
_resolve_remote
if [[ -n "$REMOTE_URL" ]]; then
gstack_remote_slug "$REMOTE_URL"
ID_REMOTE="$_gri_canon"
ID_ROOT="$REMOTE_ROOT"
if [[ -n "$_gri_legacy_slug" && "$_gri_slug" != "$_gri_legacy_slug" && "$_gri_legacy_slug" != "$SLUG" ]]; then
ID_LEGACY="$_gri_legacy_slug"
fi
else
ID_ROOT="${PROJECT_ROOT:-$(_outermost_project_root "$PROJECT_DIR")}"
ID_ROOT="${ID_ROOT:-$PROJECT_DIR}"
fi
ID_ROOT=$(cd "$ID_ROOT" 2>/dev/null && pwd -P) || ID_ROOT="$PROJECT_DIR"
}
# Prints "<remote>\037<project_root>" from the file's leading frontmatter only.
_checkpoint_fields() {
awk 'NR == 1 { if ($0 != "---") exit; next }
$0 == "---" { exit }
/^remote:/ && !r { v = $0; sub(/^remote:[ \t]*/, "", v); sub(/[ \t\r]+$/, "", v); rem = v; r = 1 }
/^project_root:/ && !p { v = $0; sub(/^project_root:[ \t]*/, "", v); sub(/[ \t\r]+$/, "", v); root = v; p = 1 }
END { printf "%s\037%s\n", rem, root }' "$1" 2>/dev/null || true
}
# Sets _ck_status: match | match-root (same remote, different root: info only)
# | foreign | unstamped (checkpoint written before identity stamping existed).
_classify_checkpoint() {
local fields r root
fields=$(_checkpoint_fields "$1")
r="${fields%%$'\037'*}"
root="${fields#*$'\037'}"
if [[ -n "$ID_REMOTE" && -n "$r" ]]; then
if [[ "$r" != "$ID_REMOTE" ]]; then _ck_status=foreign
elif [[ -n "$root" && "$root" != "$ID_ROOT" ]]; then _ck_status=match-root
else _ck_status=match
fi
elif [[ -n "$root" ]]; then
if [[ "$root" == "$ID_ROOT" ]]; then _ck_status=match; else _ck_status=foreign; fi
elif [[ -n "$r" ]]; then
_ck_status=foreign
else
_ck_status=unstamped
fi
}
_resolve_identity
case "$MODE" in
identity)
echo "SLUG=$SLUG"
echo "BRANCH=$BRANCH"
printf 'PROJECT_REMOTE=%q\n' "$ID_REMOTE"
printf 'PROJECT_ROOT=%q\n' "$ID_ROOT"
echo "LEGACY_SLUG=$ID_LEGACY"
;;
stamp)
[[ -f "$MODE_ARG" ]] || { echo "gstack-slug: no such checkpoint file: $MODE_ARG" >&2; exit 1; }
STAMP_TMP=$(mktemp "${MODE_ARG}.stamp-XXXXXX") || exit 1
if GRI_REMOTE="$ID_REMOTE" GRI_ROOT="$ID_ROOT" awk '
function ids() { if (ENVIRON["GRI_REMOTE"] != "") print "remote: " ENVIRON["GRI_REMOTE"]; print "project_root: " ENVIRON["GRI_ROOT"] }
NR == 1 { if ($0 == "---") { infm = 1; print; next } print "---"; ids(); print "---" }
infm && $0 == "---" { ids(); print; infm = 0; next }
infm && (/^remote:/ || /^project_root:/) { next }
{ print }
END { if (NR == 0) { print "---"; ids(); print "---" } }' "$MODE_ARG" > "$STAMP_TMP"; then
mv "$STAMP_TMP" "$MODE_ARG"
else
rm -f "$STAMP_TMP"
exit 1
fi
;;
classify)
while IFS= read -r ck_file || [[ -n "$ck_file" ]]; do
[[ -n "$ck_file" ]] || continue
_classify_checkpoint "$ck_file"
printf '%s\t%s\n' "$_ck_status" "$ck_file"
done
;;
adopt)
if [[ -z "$ID_LEGACY" ]]; then
echo "Nothing to adopt: this project's slug ($SLUG) did not change."
exit 0
fi
OLD_BUCKET="$GSTACK_STATE_ROOT/projects/$ID_LEGACY"
NEW_BUCKET="$GSTACK_STATE_ROOT/projects/$SLUG"
if [[ ! -d "$OLD_BUCKET" ]]; then
echo "Nothing to adopt: no earlier bucket at $OLD_BUCKET."
exit 0
fi
OLD_REAL=$(cd "$OLD_BUCKET" && pwd -P)
# Sets _ad_label and _ad_copyable (1/0) for one path relative to OLD_BUCKET.
_adopt_label() {
_ad_copyable=1
case "$1" in
checkpoints/*.md)
_classify_checkpoint "$OLD_BUCKET/$1"
case "$_ck_status" in
match|match-root) _ad_label="checkpoint: matching this project" ;;
foreign) _ad_label="checkpoint: another project (not copyable)"; _ad_copyable=0 ;;
*) _ad_label="checkpoint: unknown project (saved before identity stamps)" ;;
esac ;;
*) _ad_label="no project identity: the old bucket may be shared, check before copying" ;;
esac
if [[ "$_ad_copyable" -eq 1 && -e "$NEW_BUCKET/$1" ]]; then
_ad_label="$_ad_label; already in new bucket (skipped)"; _ad_copyable=0
fi
}
_adopt_copy() {
mkdir -p "$(dirname "$NEW_BUCKET/$1")" && cp -p "$OLD_BUCKET/$1" "$NEW_BUCKET/$1" && echo "COPIED $1"
}
if [[ ${#COPY_PATHS[@]} -gt 0 ]]; then
ADOPT_RC=0
for rel in "${COPY_PATHS[@]}"; do
case "/$rel/" in
*/../*|*/./*|//*) echo "REFUSED $rel (must be a plain path relative to $OLD_BUCKET)"; ADOPT_RC=1; continue ;;
esac
src="$OLD_BUCKET/$rel"
src_dir=$(cd "$(dirname "$src")" 2>/dev/null && pwd -P) || src_dir=""
if [[ ! -f "$src" || -L "$src" || -z "$src_dir" ]] || [[ "$src_dir" != "$OLD_REAL" && "$src_dir" != "$OLD_REAL"/* ]]; then
echo "REFUSED $rel (not a regular file inside $OLD_BUCKET)"; ADOPT_RC=1; continue
fi
_adopt_label "$rel"
if [[ "$_ad_copyable" -eq 0 ]]; then
echo "SKIPPED $rel ($_ad_label)"; continue
fi
_adopt_copy "$rel" || ADOPT_RC=1
done
exit "$ADOPT_RC"
fi
echo "gstack now files this project under: $NEW_BUCKET"
echo "Earlier data (shared nested-group bucket): $OLD_BUCKET"
echo "Nothing is copied unless you confirm it. Files:"
ADOPT_FILES=$(cd "$OLD_BUCKET" && find . -type f 2>/dev/null | sed 's#^\./##' | LC_ALL=C sort)
INTERACTIVE=0
if [[ -t 0 && -t 1 ]]; then INTERACTIVE=1; fi
while IFS= read -r rel; do
[[ -n "$rel" ]] || continue
_adopt_label "$rel"
echo " $rel [$_ad_label]"
if [[ "$INTERACTIVE" -eq 1 && "$_ad_copyable" -eq 1 ]]; then
printf ' copy into the new bucket? [y/N] '
ans=""
read -r ans </dev/tty || ans=""
case "$ans" in y|Y|yes|YES) _adopt_copy "$rel" ;; esac
fi
done <<ADOPT_LIST
$ADOPT_FILES
ADOPT_LIST
if [[ "$INTERACTIVE" -eq 0 ]]; then
echo "Nothing copied. Copy chosen files with: gstack-slug --adopt-legacy --copy <path> [<path>...]"
fi
;;
esac
+10 -6
View File
@@ -1,14 +1,18 @@
#!/usr/bin/env bash
# Output the remote slug (owner-repo) for the current git repo.
# Output the remote slug for the current git repo.
# Used by SKILL.md files to derive project-specific paths in ~/.gstack/projects/.
# The slug rule (owner-repo for 2-segment remotes, "<last-two>-<16 hex>" for
# 3+-segment nested-group remotes, #3003) is shared with bin/gstack-slug through
# bin/gstack-remote-identity.sh.
set -e
. "$(dirname "$0")/../../bin/gstack-remote-identity.sh" 2>/dev/null || { echo "$0: bin/gstack-remote-identity.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; }
URL=$(git remote get-url origin 2>/dev/null || true)
_gri_slug=""
if [ -n "$URL" ]; then
# Strip trailing .git if present, then extract owner/repo
URL="${URL%.git}"
# Handle both SSH (git@host:owner/repo) and HTTPS (https://host/owner/repo)
OWNER_REPO=$(echo "$URL" | sed -E 's#.*[:/]([^/]+)/([^/]+)$#\1-\2#')
echo "$OWNER_REPO"
gstack_remote_slug "$URL"
fi
if [ -n "$_gri_slug" ]; then
echo "$_gri_slug"
else
basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
fi
+6 -6
View File
@@ -15,6 +15,7 @@ import * as path from 'path';
import { mkdirSecure } from './file-permissions';
import { safeUnlinkQuiet } from './error-handling';
import { readConfigKey, resolveStateRoot } from '../../lib/state-root';
import { remoteSlug } from '../../lib/remote-identity';
export interface BrowseConfig {
projectDir: string;
@@ -156,7 +157,9 @@ export function ensureStateDir(config: BrowseConfig): void {
}
/**
* Derive a slug from the git remote origin URL (owner-repo format).
* Derive a slug from the git remote origin URL via the shared rule in
* lib/remote-identity.ts (owner-repo; "<last-two>-<16 hex>" for 3+-segment
* nested-group remotes, #3003), so browse files state where gstack-slug does.
* Falls back to the directory basename if no remote is configured.
*/
export function getRemoteSlug(): string {
@@ -168,11 +171,8 @@ export function getRemoteSlug(): string {
timeout: 2_000,
});
if (proc.exitCode !== 0) throw new Error('no remote');
const url = proc.stdout.toString().trim();
// SSH: git@github.com:owner/repo.git → owner-repo
// HTTPS: https://github.com/owner/repo.git → owner-repo
const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
if (match) return `${match[1]}-${match[2]}`;
const slug = remoteSlug(proc.stdout.toString().trim());
if (slug) return slug;
throw new Error('unparseable');
} catch {
const root = getGitRoot();
+67 -7
View File
@@ -422,6 +422,16 @@ eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gsta
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p "$GSTACK_STATE_ROOT/projects/$SLUG" && echo "PROJECT_DIR: $GSTACK_STATE_ROOT/projects/$SLUG"
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
CHECKPOINT_DIR="$GSTACK_STATE_ROOT/projects/$SLUG/checkpoints"
# Project identity: canonical remote (root only when there is no remote).
eval "$(~/.claude/skills/gstack/bin/gstack-slug --identity 2>/dev/null)" || true
echo "PROJECT_IDENTITY: ${PROJECT_REMOTE:-${PROJECT_ROOT:-unknown}}"
echo "PROJECT_ROOT: ${PROJECT_ROOT:-unknown}"
if [ -n "${LEGACY_SLUG:-}" ] && [ -d "$GSTACK_STATE_ROOT/projects/$LEGACY_SLUG/checkpoints" ]; then
LEGACY_N=$(find "$GSTACK_STATE_ROOT/projects/$LEGACY_SLUG/checkpoints" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l | tr -d ' ')
if [ "$LEGACY_N" -gt 0 ]; then
echo "LEGACY_BUCKET: projects/$LEGACY_SLUG holds $LEGACY_N earlier checkpoint(s) from the bucket this project shared with other nested-group repos; review and copy with: ~/.claude/skills/gstack/bin/gstack-slug --adopt-legacy"
fi
fi
if [ ! -d "$CHECKPOINT_DIR" ]; then
echo "NO_CHECKPOINTS"
else
@@ -458,9 +468,28 @@ else
done <<EOF
$ALL
EOF
# Cap at 20: a user with 10k saved files shouldn't blow the context window.
FILES=$(printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' | head -20)
echo "$FILES"
# Identity check (#3003): only checkpoints stamped with THIS project's
# identity are candidates for "latest". Unstamped (older) checkpoints are
# trusted unless the directory demonstrably holds another project's files.
CLASSIFIED=$(printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' \
| ~/.claude/skills/gstack/bin/gstack-slug --classify-checkpoints 2>/dev/null)
if [ -z "$CLASSIFIED" ]; then
echo "IDENTITY_CHECK_FAILED"
printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' | head -20 | sed 's/^/UNVERIFIED /'
else
FOREIGN_N=$(printf '%s\n' "$CLASSIFIED" | grep -c '^foreign' || true)
# Cap at 20: a user with 10k saved files shouldn't blow the context window.
FILES=$(printf '%s\n' "$CLASSIFIED" | awk -F '\t' -v f="$FOREIGN_N" \
'$1 == "match" || $1 == "match-root" || ($1 == "unstamped" && f == 0) { print $2 }' | head -20)
if [ -n "$FILES" ]; then echo "$FILES"; else echo "NO_VERIFIED_CHECKPOINTS"; fi
printf '%s\n' "$CLASSIFIED" | awk -F '\t' -v f="$FOREIGN_N" \
'$1 == "match-root" { print "ROOT_DIFFERS " $2 }
$1 == "foreign" { print "FOREIGN " $2 }
$1 == "unstamped" && f > 0 { print "UNVERIFIED " $2 }' | head -40
if [ "$FOREIGN_N" -gt 0 ]; then
echo "SHARED_BUCKET: $FOREIGN_N checkpoint(s) here were saved by another project"
fi
fi
fi
fi
```
@@ -470,13 +499,38 @@ fi
frontmatter). Other-branch files stay in the set as a fallback, which preserves
Conductor workspace handoff when the current branch has no checkpoint of its own.
**Project identity.** Plain path lines are this project's checkpoints (verified by
the `remote:`/`project_root:` stamp, or unstamped in a directory no other project
has written to). Prefixed lines are never candidates for "latest":
- `FOREIGN <path>`: saved by a different project that shares this directory.
- `UNVERIFIED <path>`: saved before identity stamps, in a directory another
project also uses, so it may belong to either.
- `ROOT_DIFFERS <path>`: same repository, different checkout (Conductor workspace
handoff or a moved clone). This is information, not a mismatch; the file is
also listed as a plain path line.
### Step 2: Load the right file
- If the user specified a title fragment or number: find the matching file among
the candidates.
- Otherwise: load the **first file returned by Step 1 above** — that is the
newest `YYYYMMDD-HHMMSS` checkpoint for the current branch, or, if the current
branch has none, the newest across all branches.
the candidates, including `FOREIGN` and `UNVERIFIED` lines (a user may restore
another project's checkpoint deliberately).
- Otherwise: load the **first plain path line returned by Step 1 above**, which
is the newest `YYYYMMDD-HHMMSS` checkpoint of this project for the current
branch, or, if the current branch has none, the newest across all branches.
- If Step 1 printed `NO_VERIFIED_CHECKPOINTS` or `IDENTITY_CHECK_FAILED`, do not
present any checkpoint as the latest one. Say "No checkpoint in this directory
is verified as this project's", list the `FOREIGN`/`UNVERIFIED` titles with the
`remote:`/`project_root:` they name (or "unknown"), and ask which one, if any, to
open.
**Report identity problems before any summary.** If the chosen file is `FOREIGN`
or `UNVERIFIED`, start with:
"PROJECT MISMATCH: this checkpoint was saved by `{remote or project_root from its
frontmatter, or 'an unknown project'}`, not this project (`{PROJECT_IDENTITY}`)."
If Step 1 printed `SHARED_BUCKET`, say "This checkpoints directory also holds N
checkpoint(s) from another project; only this project's are listed." If it printed
`LEGACY_BUCKET`, relay that line. If the chosen file is `ROOT_DIFFERS`, add
"Saved from another checkout of this repository at `{project_root}`." as info.
Read the chosen file and present a summary:
@@ -523,6 +577,9 @@ If Step 1 printed `NO_CHECKPOINTS`, tell the user:
"No saved contexts yet. Run `/context-save` first to save your current working
state, then `/context-restore` will find it."
If it also printed `LEGACY_BUCKET`, relay that line: earlier checkpoints are kept
in the old bucket until the user copies them with `gstack-slug --adopt-legacy`.
---
## Important Rules
@@ -532,6 +589,9 @@ state, then `/context-restore` will find it."
fallback set.** Cross-branch resume (Conductor handoff) still works when the
current branch has no checkpoint; it just no longer lets a sibling worktree's
newer save shadow this branch's own.
- **Never present another project's checkpoint as "latest".** Identity is the
canonical remote (the root only when there is no remote); a mismatch is
reported before any summary.
- **"Most recent" means the filename `YYYYMMDD-HHMMSS` prefix**, not
`ls -1t` (filesystem mtime). Filenames are stable across file-system
operations; mtime is not.
+67 -7
View File
@@ -70,6 +70,16 @@ Parse the user's input:
{{SLUG_SETUP}}
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
CHECKPOINT_DIR="$GSTACK_STATE_ROOT/projects/$SLUG/checkpoints"
# Project identity: canonical remote (root only when there is no remote).
eval "$(~/.claude/skills/gstack/bin/gstack-slug --identity 2>/dev/null)" || true
echo "PROJECT_IDENTITY: ${PROJECT_REMOTE:-${PROJECT_ROOT:-unknown}}"
echo "PROJECT_ROOT: ${PROJECT_ROOT:-unknown}"
if [ -n "${LEGACY_SLUG:-}" ] && [ -d "$GSTACK_STATE_ROOT/projects/$LEGACY_SLUG/checkpoints" ]; then
LEGACY_N=$(find "$GSTACK_STATE_ROOT/projects/$LEGACY_SLUG/checkpoints" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l | tr -d ' ')
if [ "$LEGACY_N" -gt 0 ]; then
echo "LEGACY_BUCKET: projects/$LEGACY_SLUG holds $LEGACY_N earlier checkpoint(s) from the bucket this project shared with other nested-group repos; review and copy with: ~/.claude/skills/gstack/bin/gstack-slug --adopt-legacy"
fi
fi
if [ ! -d "$CHECKPOINT_DIR" ]; then
echo "NO_CHECKPOINTS"
else
@@ -106,9 +116,28 @@ else
done <<EOF
$ALL
EOF
# Cap at 20: a user with 10k saved files shouldn't blow the context window.
FILES=$(printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' | head -20)
echo "$FILES"
# Identity check (#3003): only checkpoints stamped with THIS project's
# identity are candidates for "latest". Unstamped (older) checkpoints are
# trusted unless the directory demonstrably holds another project's files.
CLASSIFIED=$(printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' \
| ~/.claude/skills/gstack/bin/gstack-slug --classify-checkpoints 2>/dev/null)
if [ -z "$CLASSIFIED" ]; then
echo "IDENTITY_CHECK_FAILED"
printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' | head -20 | sed 's/^/UNVERIFIED /'
else
FOREIGN_N=$(printf '%s\n' "$CLASSIFIED" | grep -c '^foreign' || true)
# Cap at 20: a user with 10k saved files shouldn't blow the context window.
FILES=$(printf '%s\n' "$CLASSIFIED" | awk -F '\t' -v f="$FOREIGN_N" \
'$1 == "match" || $1 == "match-root" || ($1 == "unstamped" && f == 0) { print $2 }' | head -20)
if [ -n "$FILES" ]; then echo "$FILES"; else echo "NO_VERIFIED_CHECKPOINTS"; fi
printf '%s\n' "$CLASSIFIED" | awk -F '\t' -v f="$FOREIGN_N" \
'$1 == "match-root" { print "ROOT_DIFFERS " $2 }
$1 == "foreign" { print "FOREIGN " $2 }
$1 == "unstamped" && f > 0 { print "UNVERIFIED " $2 }' | head -40
if [ "$FOREIGN_N" -gt 0 ]; then
echo "SHARED_BUCKET: $FOREIGN_N checkpoint(s) here were saved by another project"
fi
fi
fi
fi
```
@@ -118,13 +147,38 @@ fi
frontmatter). Other-branch files stay in the set as a fallback, which preserves
Conductor workspace handoff when the current branch has no checkpoint of its own.
**Project identity.** Plain path lines are this project's checkpoints (verified by
the `remote:`/`project_root:` stamp, or unstamped in a directory no other project
has written to). Prefixed lines are never candidates for "latest":
- `FOREIGN <path>`: saved by a different project that shares this directory.
- `UNVERIFIED <path>`: saved before identity stamps, in a directory another
project also uses, so it may belong to either.
- `ROOT_DIFFERS <path>`: same repository, different checkout (Conductor workspace
handoff or a moved clone). This is information, not a mismatch; the file is
also listed as a plain path line.
### Step 2: Load the right file
- If the user specified a title fragment or number: find the matching file among
the candidates.
- Otherwise: load the **first file returned by Step 1 above** — that is the
newest `YYYYMMDD-HHMMSS` checkpoint for the current branch, or, if the current
branch has none, the newest across all branches.
the candidates, including `FOREIGN` and `UNVERIFIED` lines (a user may restore
another project's checkpoint deliberately).
- Otherwise: load the **first plain path line returned by Step 1 above**, which
is the newest `YYYYMMDD-HHMMSS` checkpoint of this project for the current
branch, or, if the current branch has none, the newest across all branches.
- If Step 1 printed `NO_VERIFIED_CHECKPOINTS` or `IDENTITY_CHECK_FAILED`, do not
present any checkpoint as the latest one. Say "No checkpoint in this directory
is verified as this project's", list the `FOREIGN`/`UNVERIFIED` titles with the
`remote:`/`project_root:` they name (or "unknown"), and ask which one, if any, to
open.
**Report identity problems before any summary.** If the chosen file is `FOREIGN`
or `UNVERIFIED`, start with:
"PROJECT MISMATCH: this checkpoint was saved by `{remote or project_root from its
frontmatter, or 'an unknown project'}`, not this project (`{PROJECT_IDENTITY}`)."
If Step 1 printed `SHARED_BUCKET`, say "This checkpoints directory also holds N
checkpoint(s) from another project; only this project's are listed." If it printed
`LEGACY_BUCKET`, relay that line. If the chosen file is `ROOT_DIFFERS`, add
"Saved from another checkout of this repository at `{project_root}`." as info.
Read the chosen file and present a summary:
@@ -171,6 +225,9 @@ If Step 1 printed `NO_CHECKPOINTS`, tell the user:
"No saved contexts yet. Run `/context-save` first to save your current working
state, then `/context-restore` will find it."
If it also printed `LEGACY_BUCKET`, relay that line: earlier checkpoints are kept
in the old bucket until the user copies them with `gstack-slug --adopt-legacy`.
---
## Important Rules
@@ -180,6 +237,9 @@ state, then `/context-restore` will find it."
fallback set.** Cross-branch resume (Conductor handoff) still works when the
current branch has no checkpoint; it just no longer lets a sibling worktree's
newer save shadow this branch's own.
- **Never present another project's checkpoint as "latest".** Identity is the
canonical remote (the root only when there is no remote); a mismatch is
reported before any summary.
- **"Most recent" means the filename `YYYYMMDD-HHMMSS` prefix**, not
`ls -1t` (filesystem mtime). Filenames are stable across file-system
operations; mtime is not.
+13 -1
View File
@@ -532,7 +532,19 @@ files_modified:
```
The `files_modified` list comes from `git status --short` (both staged and unstaged
modified files). Use relative paths from the repo root.
modified files). Use relative paths from the repo root. Do not write `remote:` or
`project_root:` yourself; the stamp step below adds them.
Then stamp the project identity. Several projects can share one checkpoints
directory (GitLab nested groups did before #3003), so `/context-restore` needs to
know which project wrote each file. The helper adds `remote:` (canonical origin:
lowercased host, no credentials, port or `.git`) and `project_root:` to the
frontmatter:
```bash
FILE="<the FILE path printed above>"
~/.claude/skills/gstack/bin/gstack-slug --stamp-checkpoint "$FILE" && echo "STAMPED"
```
After writing, confirm to the user:
+13 -1
View File
@@ -180,7 +180,19 @@ files_modified:
```
The `files_modified` list comes from `git status --short` (both staged and unstaged
modified files). Use relative paths from the repo root.
modified files). Use relative paths from the repo root. Do not write `remote:` or
`project_root:` yourself; the stamp step below adds them.
Then stamp the project identity. Several projects can share one checkpoints
directory (GitLab nested groups did before #3003), so `/context-restore` needs to
know which project wrote each file. The helper adds `remote:` (canonical origin:
lowercased host, no credentials, port or `.git`) and `project_root:` to the
frontmatter:
```bash
FILE="<the FILE path printed above>"
~/.claude/skills/gstack/bin/gstack-slug --stamp-checkpoint "$FILE" && echo "STAMPED"
```
After writing, confirm to the user:
+44
View File
@@ -90,6 +90,50 @@ Trust-policy deny tiers merge the same way: a `deny` or `read-only` entry in
`~/.gstack/gbrain-repo-policy.json` still applies when your resolved root is
elsewhere. Nothing is ever written to the other root.
## Project buckets
Per-project state (checkpoints, learnings, timeline, question preferences)
lives in `projects/<slug>/`. `bin/gstack-slug` picks the slug from the git
`origin` remote:
- A 2-segment remote (`github.com/owner/repo`) files under `owner-repo`.
- A remote with 3 or more path segments (GitLab nested groups such as
`gitlab.com/customer-a/product/repo`, Azure DevOps) files under
`product-repo-<16 hex>`. The hex is a digest of the canonical remote, so
`customer-a/product/repo` and `customer-b/product/repo` get separate buckets,
and the ssh, https and ssh-with-port spellings of one repository share one.
- Without a remote, the project root's directory name is used.
The first slug gstack resolves in a directory sticks (it is cached under
`slug-cache/`), so a project keeps its bucket when it adds a remote later.
**Upgrading from a version that used one bucket for nested groups.** Earlier
versions filed every `<group>/product/repo` under `product-repo`. That bucket
stays where it is; gstack neither moves nor deletes it. The first time gstack
resolves the new slug it prints one notice naming both buckets, and
`/context-restore` reports the old bucket while it still holds checkpoints. To
bring earlier files across, run this in the project:
```bash
~/.claude/skills/gstack/bin/gstack-slug --adopt-legacy
```
It lists every file in the old bucket. Checkpoints are marked as matching this
project, another project (never copied), or unknown (saved before checkpoints
recorded their project). Files with no project identity (question
preferences, learnings, timeline) may belong to any project that shared the
bucket, so they are listed for you to decide. On a terminal it asks per file;
otherwise copy chosen files with
`gstack-slug --adopt-legacy --copy <path> [<path>...]`. Nothing is copied
without your confirmation, and nothing in the new bucket is overwritten.
**Pinning a slug.** Set `GSTACK_PROJECT_SLUG=<name>` to choose the bucket
yourself, for example in the project's `.claude/settings.local.json`
(`"env": {"GSTACK_PROJECT_SLUG": "<name>"}`). Use it when an ssh host alias
(`git@work-gitlab:group/sub/repo`) gives a different host than the https
remote, or for a vendored sub-repo that should be its own project. The pin is
applied per run and never cached.
## Uninstall
`gstack-uninstall` deletes state only at `~/.gstack`.
+30 -10
View File
@@ -10,7 +10,9 @@
* enforcement is skipped for this question (D18 — hash IDs are
* observed-only, never used as preference keys).
* 2. Look up door_type from scripts/question-registry.ts (default two-way).
* 3. Read preferences with precedence: project-local > global (D8).
* 3. Resolve the project bucket from gstack-slug's versioned slug-cache
* entry for cwd (#2901); unresolved identity fails open to ASKING.
* Read preferences with precedence: project-local > global (D8).
* 4. Apply:
* never-ask + one-way → pass through (safety override; one-way always asks).
* never-ask + two-way + marker → deny with auto-decided recommendation
@@ -47,6 +49,7 @@ import { isConductor } from '../../../lib/is-conductor';
import { classifyQuestion } from '../../../scripts/one-way-doors';
import { SPAWNED_ESCAPE_SENTENCE, CONDUCTOR_SPAWNED_DENY_REASON, spawnedByEnv } from './spawned-directive';
import { resolveStateRoot } from '../../../lib/state-root';
import { readVersionedSlugCache } from '../../../lib/bin-context';
import { logHookError as sharedLogHookError } from './hook-log';
interface HookStdin {
@@ -283,13 +286,25 @@ function extractRecommended(
return { recommended: undefined, ambiguous: false };
}
function slugFromCwd(cwd: string | undefined): string {
// Mirror gstack-slug's basename fallback. The full slug resolver shells out
// to git, which is too expensive on a hot hook path; the basename is close
// enough for preference lookup (preferences are keyed by question_id, slug
// is just the directory bucket).
if (!cwd) return 'unknown';
return path.basename(cwd);
type ProjectSlug = { slug: string } | { unresolved: string };
/**
* The bucket gstack-question-preference writes to (#2901): the same slug
* bin/gstack-slug resolves, read from its versioned per-path slug-cache entry
* (lib/bin-context.ts key encoding) so this hot path never spawns git. The
* skill preamble runs gstack-slug in this cwd before any question, so a miss
* means identity is genuinely unknown: the caller then asks instead of
* auto-deciding (a basename guess filed never-ask under a bucket no writer
* uses, and a project-local always-ask could be silently overridden by a
* global never-ask). GSTACK_PROJECT_SLUG wins, as in gstack-slug.
*/
function resolveProjectSlug(cwd: string | undefined): ProjectSlug {
const envSlug = (process.env.GSTACK_PROJECT_SLUG || '').trim().replace(/[^a-zA-Z0-9._-]/g, '');
if (envSlug && envSlug !== '.' && envSlug !== '..') return { slug: envSlug };
if (!cwd) return { unresolved: 'the hook input has no cwd' };
const cached = readVersionedSlugCache(stateRoot(), cwd);
if (cached) return { slug: cached };
return { unresolved: `no versioned gstack-slug cache entry for ${cwd}` };
}
function markAutoDecided(sessionId: string | undefined, toolUseId: string | undefined): void {
@@ -378,7 +393,7 @@ async function main(): Promise<void> {
// we deny only if ALL questions have marker + never-ask + safe door type.
// Mixed cases pass through so the user still gets to answer.
const registry = loadRegistry();
const slug = slugFromCwd(stdin.cwd);
const project = resolveProjectSlug(stdin.cwd);
const memoryNuggets = loadMemoryNuggets(stdin.session_id);
// Compute Layer 8 memory context inline: any nuggets matching the
@@ -414,7 +429,12 @@ async function main(): Promise<void> {
const marker = qText.match(MARKER_RE);
if (!marker) { fullyAutoDecidable = false; break; }
const questionId = marker[1];
const pref = lookupPreference(slug, questionId);
if ('unresolved' in project) {
logHookError(`never-ask not applied to ${questionId}, asking instead: project identity unresolved (${project.unresolved})`);
fullyAutoDecidable = false;
break;
}
const pref = lookupPreference(project.slug, questionId);
if (!pref.preference || pref.preference === 'always-ask') { fullyAutoDecidable = false; break; }
const entry = registry[questionId];
+62 -18
View File
@@ -9,6 +9,7 @@ import { spawnSync } from "child_process";
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "fs";
import { basename, dirname, join } from "path";
import { resolveStateRoot } from "./state-root";
import { legacyRemoteSlug, remoteSlug } from "./remote-identity";
/** Keep the slug inside the [a-zA-Z0-9._-] alphabet gstack-slug promises (`tr -cd`). */
function sanitizeSlug(s: string): string {
@@ -125,7 +126,10 @@ export function outermostRemoteRepo(startDir: string): { root: string; url: stri
* 1. Walk UP to the OUTERMOST project root (see outermostProjectRoot). Without
* the walk, a nested/vendored repo derived its slug from the INNERMOST
* `git remote get-url origin`, splitting the store the bash side keeps whole.
* 2. Cached slug is sticky (#2212) — EXCEPT two provable bug shapes:
* 2. Cached slug is sticky (#2212) — EXCEPT two provable bug shapes. Entries
* are versioned ("v2:<slug>"); an unversioned entry is recomputed once and
* moves only when it equals the pre-#3003 last-two slug of a 3+-segment
* remote:
* - old-bug shape (#1125): cached value equals basename(cwd) while the
* walk-up says cwd is NOT the project root; that cache came from the
* pre-walk-up resolver, so recompute and heal.
@@ -141,21 +145,50 @@ export function outermostRemoteRepo(startDir: string): { root: string; url: stri
* project root), so the heal never fires.
* 3. Canonical remote-derived slug from the OUTERMOST remote-bearing repo
* (see outermostRemoteRepo — never PROJECT_ROOT, which may be a
* marker-only ancestor with no remote): [:/]<owner>/<repo>[.git] →
* owner-repo, byte-parity with browse/bin/remote-slug. Degenerate slugs
* marker-only ancestor with no remote): remoteSlug() from
* lib/remote-identity.ts — owner-repo for 2-segment and local remotes,
* "<last-two>-<16 hex>" for 3+-segment hosted remotes (#3003) —
* byte-parity with browse/bin/remote-slug. Degenerate slugs
* ("", ".", "..", anything with "/") are rejected — a hostile origin
* like `url = ..` must never escape ~/.gstack/projects/<slug>.
* 4. Project root's basename; else basename(cwd) for plain non-project folders.
*/
/** gstack-slug's per-path cache file: the cwd in MSYS form with "/" → "_". */
export function slugCacheFile(stateRoot: string, cwd: string): string {
return join(stateRoot, "slug-cache", toMsysPath(cwd).replace(/\//g, "_"));
}
/** Cache entries written since #3003 carry this version prefix ("v2:<slug>"). */
export const SLUG_CACHE_VERSION_PREFIX = "v2:";
/**
* The slug a versioned cache entry records for `cwd`, or undefined when there is
* no entry, the entry predates the versioned format, or its value is unusable
* (empty, "." or ".." after sanitizing). Never spawns git: hot-path readers
* (the AskUserQuestion preference hook) rely on gstack-slug, which every skill
* preamble runs, having already written the entry.
*/
export function readVersionedSlugCache(stateRoot: string, cwd: string): string | undefined {
let raw: string;
try {
raw = readFileSync(slugCacheFile(stateRoot, cwd), "utf-8");
} catch {
return undefined;
}
if (!raw.startsWith(SLUG_CACHE_VERSION_PREFIX)) return undefined;
const slug = sanitizeSlug(raw.slice(SLUG_CACHE_VERSION_PREFIX.length).trim());
return slug && slug !== "." && slug !== ".." ? slug : undefined;
}
export function slugFromEnvironment(gstackHome?: string, cwd: string = process.cwd()): string {
const home = gstackHome || resolveStateRoot();
const cacheDir = join(home, "slug-cache");
const cacheFile = join(cacheDir, toMsysPath(cwd).replace(/\//g, "_"));
const cacheFile = slugCacheFile(home, cwd);
// 0. explicit env override — per-invocation escape hatch, never persisted
// (caching it would rebind THIS cwd's slug for every later env-less run).
const envSlug = sanitizeSlug((process.env.GSTACK_PROJECT_SLUG || "").trim());
if (envSlug) return envSlug;
if (envSlug && envSlug !== "." && envSlug !== "..") return envSlug;
// 1. outermost project root along the cwd ancestor chain (may be "").
const projectRoot = outermostProjectRoot(cwd);
@@ -171,7 +204,9 @@ export function slugFromEnvironment(gstackHome?: string, cwd: string = process.c
// (old-bug #1125 and degraded-ancestor 2026-08-17 — see the doc above).
if (existsSync(cacheFile)) {
try {
const cached = sanitizeSlug(readFileSync(cacheFile, "utf-8").trim());
const raw = readFileSync(cacheFile, "utf-8");
const versioned = raw.startsWith(SLUG_CACHE_VERSION_PREFIX);
const cached = sanitizeSlug((versioned ? raw.slice(SLUG_CACHE_VERSION_PREFIX.length) : raw).trim());
if (cached) {
const pwdBase = sanitizeSlug(basename(cwd));
const rootBase = projectRoot ? sanitizeSlug(basename(projectRoot)) : "";
@@ -193,24 +228,33 @@ export function slugFromEnvironment(gstackHome?: string, cwd: string = process.c
const r = resolveRemote();
return r.url !== "" && r.root !== projectRoot;
})();
if (!oldBugShape && !degradedAncestorShape) slug = cached;
if (!oldBugShape && !degradedAncestorShape) {
slug = cached;
// Unversioned (pre-#3003) entry: recompute once. Keep the sticky
// value unless it is exactly the old last-two slug of a 3+-segment
// remote (mirrors bin/gstack-slug).
if (!versioned) {
const { url } = resolveRemote();
if (url) {
const upgraded = remoteSlug(url);
const legacy = legacyRemoteSlug(url);
if (upgraded && upgraded !== legacy && cached === legacy) slug = upgraded;
}
}
}
}
} catch {
slug = "";
}
}
// 3. canonical remote-derived slug from the outermost remote-bearing repo.
// Parse mirrors bin/gstack-slug step 2 exactly (byte-parity with
// browse/bin/remote-slug): `${REMOTE_URL%.git}` strips ONE trailing
// ".git" (case-sensitive), then sed extracts the LAST two path segments
// — and sed's no-match passthrough means the stripped URL itself is the
// raw slug when no [:/]owner/repo tail exists.
// 3. canonical remote-derived slug from the outermost remote-bearing repo,
// via the shared rule in lib/remote-identity.ts (twin of
// bin/gstack-remote-identity.sh): legacy last-two parse for 2-segment and
// local remotes, "<last-two>-<16 hex>" for 3+-segment hosted remotes.
if (!slug) {
const { url } = resolveRemote();
if (url) {
const stripped = url.endsWith(".git") ? url.slice(0, -4) : url;
const m = stripped.match(/[:/]([^/]+)\/([^/]+)$/);
const candidate = sanitizeSlug(m ? `${m[1]}-${m[2]}` : stripped);
const candidate = remoteSlug(url);
// Dot-only / degenerate guard (mirrors bin/gstack-slug): a hostile
// origin like `url = ..` yields "." or ".." here, which would file
// state OUTSIDE ~/.gstack/projects/. Reject and let the basename
@@ -234,10 +278,10 @@ export function slugFromEnvironment(gstackHome?: string, cwd: string = process.c
} catch {
// no cache yet — write below
}
if (current !== slug) {
if (current !== `${SLUG_CACHE_VERSION_PREFIX}${slug}`) {
mkdirSync(cacheDir, { recursive: true });
const tmp = `${cacheFile}.tmp.${process.pid}`;
writeFileSync(tmp, slug, "utf-8");
writeFileSync(tmp, `${SLUG_CACHE_VERSION_PREFIX}${slug}`, "utf-8");
renameSync(tmp, cacheFile);
}
} catch {
+109
View File
@@ -0,0 +1,109 @@
/**
* remote-identity — the one TypeScript owner of git-remote project identity
* (twin of bin/gstack-remote-identity.sh; test/remote-identity-vectors.test.ts
* runs both against test/fixtures/remote-identity-vectors.json).
*
* canonicalRemote: one spelling per repository whichever way it was cloned
* (#3003). Hosted remotes become "<host>/<seg>/…" with the host lowercased
* (ASCII only, locale-independent), userinfo, credentials and port dropped,
* one trailing ".git" and stray slashes removed, and host-specific
* normalization applied before segments are counted (Azure DevOps "v3/" and
* "_git", *.visualstudio.com → dev.azure.com/<org>/…, Bitbucket Server "scm/").
* Local remotes keep their path as identity.
*
* remoteSlug: hosted remotes with 3+ segments file under
* "<last-two>-<16 hex of sha256(canonical)>"; everything else keeps the legacy
* last-two parse byte-for-byte, so no 2-segment (GitHub) bucket moves.
*/
import { createHash } from "crypto";
export interface CanonicalRemote {
canonical: string;
hosted: boolean;
segments: string[];
}
const SLUG_ALPHABET = /[^a-zA-Z0-9._-]/g;
function asciiLower(s: string): string {
return s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
}
function stripTrailingSlashes(s: string, keepRoot: boolean): string {
let out = s;
while ((keepRoot ? out.length > 1 : out.length > 0) && out.endsWith("/")) out = out.slice(0, -1);
return out;
}
export function canonicalRemote(rawUrl: string): CanonicalRemote {
const url = rawUrl.replace(/[\r\n]/g, "");
if (!url) return { canonical: "", hosted: false, segments: [] };
let host = "";
let path = "";
let hosted = false;
const schemeMatch = url.match(/^([A-Za-z][A-Za-z0-9+.-]*):\/\/(.*)$/);
const scpMatch = url.match(/^([^/:]+):(.*)$/);
if (schemeMatch) {
const rest = schemeMatch[2];
if (asciiLower(schemeMatch[1]) !== "file") {
const slash = rest.indexOf("/");
let auth = slash === -1 ? rest : rest.slice(0, slash);
path = slash === -1 ? "" : rest.slice(slash + 1);
auth = auth.slice(auth.lastIndexOf("@") + 1);
const v6 = auth.match(/^(\[[^\]]*\])/);
host = v6 ? v6[1] : auth.split(":")[0];
hosted = true;
} else {
path = rest;
}
} else if (scpMatch && !/^[A-Za-z]:([/\\]|$)/.test(url)) {
host = scpMatch[1].slice(scpMatch[1].lastIndexOf("@") + 1);
path = scpMatch[2];
hosted = true;
} else {
path = url;
}
if (!hosted) {
let p = stripTrailingSlashes(path, true);
if (p.endsWith(".git")) p = p.slice(0, -4);
p = stripTrailingSlashes(p, true);
return { canonical: p, hosted: false, segments: [] };
}
host = asciiLower(host);
path = stripTrailingSlashes(path, false);
if (path.endsWith(".git")) path = path.slice(0, -4);
let segments = path.split("/").filter((s) => s !== "");
if (host === "ssh.dev.azure.com" || host === "vs-ssh.visualstudio.com") {
if (segments[0] === "v3") segments = segments.slice(1);
host = "dev.azure.com";
} else if (host !== "dev.azure.com" && host.endsWith(".visualstudio.com")) {
if (segments[0] === "DefaultCollection") segments = segments.slice(1);
segments = [host.split(".")[0], ...segments];
host = "dev.azure.com";
} else if (host !== "dev.azure.com" && segments.length >= 3 && segments[0] === "scm") {
segments = segments.slice(1);
}
if (host === "dev.azure.com") segments = segments.filter((s) => s !== "_git");
return { canonical: [host, ...segments].join("/"), hosted: true, segments };
}
/** The pre-#3003 parse, byte-identical to bin/gstack-remote-identity.sh's gstack_legacy_remote_slug. */
export function legacyRemoteSlug(url: string): string {
const stripped = url.endsWith(".git") ? url.slice(0, -4) : url;
const m = stripped.match(/[:/]([^/]+)\/([^/]+)$/);
const slug = (m ? `${m[1]}-${m[2]}` : stripped).replace(SLUG_ALPHABET, "");
return slug === "." || slug === ".." ? "" : slug;
}
/** Project slug for a remote URL ("" when degenerate). */
export function remoteSlug(url: string): string {
const id = canonicalRemote(url);
const n = id.segments.length;
if (!id.hosted || n < 3) return legacyRemoteSlug(url);
const digest = createHash("sha256").update(id.canonical).digest("hex").slice(0, 16);
return `${id.segments[n - 2]}-${id.segments[n - 1]}-${digest}`.replace(SLUG_ALPHABET, "");
}
+2 -2
View File
@@ -12,9 +12,9 @@ This is Step 1.5's plan-completion audit: discover the plan, extract actionable
setopt +o nomatch 2>/dev/null || true # zsh compat
BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')
REPO=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)")
_PLAN_SLUG=$(git remote get-url origin 2>/dev/null | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-' | tr -cd 'a-zA-Z0-9._-') || true
_PLAN_SLUG="${_PLAN_SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}"
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
_PLAN_SLUG=$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null | sed -n 's/^SLUG=//p') || true
_PLAN_SLUG="${_PLAN_SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}"
for PLAN_DIR in "$GSTACK_STATE_ROOT/projects/$_PLAN_SLUG" "$HOME/.claude/plans" "$HOME/.codex/plans" ".gstack/plans"; do
[ -d "$PLAN_DIR" ] || continue
PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$BRANCH" 2>/dev/null | head -1)
+2 -2
View File
@@ -199,9 +199,9 @@ function generatePlanFileDiscovery(ship = false): string {
setopt +o nomatch 2>/dev/null || true # zsh compat
BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')
REPO=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)")
_PLAN_SLUG=$(git remote get-url origin 2>/dev/null | sed 's|.*[:/]\\([^/]*/[^/]*\\)\\.git$|\\1|;s|.*[:/]\\([^/]*/[^/]*\\)$|\\1|' | tr '/' '-' | tr -cd 'a-zA-Z0-9._-') || true
_PLAN_SLUG="\${_PLAN_SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}"
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
_PLAN_SLUG=$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null | sed -n 's/^SLUG=//p') || true
_PLAN_SLUG="\${_PLAN_SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}"
for PLAN_DIR in "$GSTACK_STATE_ROOT/projects/$_PLAN_SLUG" "$HOME/.claude/plans" "$HOME/.codex/plans" ".gstack/plans"; do
[ -d "$PLAN_DIR" ] || continue
PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$BRANCH" 2>/dev/null | head -1)
+1 -1
View File
@@ -111,7 +111,7 @@
"test/ceo-mode-expansion-disposition.test.ts\tTS7053\tElement implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ \"D3 \\u2014 E1: Add project-shared views alongside private views?\\nProject/branch/task: main; saved project views plan, ledger row L1 (ownership scope).\\nELI10: Right now the plan saves a view for one member only. Shared views let the person who builds \\\"Blocked on design, by priority\\\" publish it to the whole proj...'. No index signature with a parameter of type 'string' was found on type '{ \"D3 \\u2014 E1: Add project-shared views alongside private views?\\nProject/branch/task: main; saved project views plan, ledger row L1 (ownership scope).\\nELI10: Right now the plan saves a view for one member only. Shared views let the person who builds \\\"Blocked on design, by priority\\\" publish it to the whole proj...'.": 1,
"test/ceo-mode-expansion-disposition.test.ts\tTS7053\tElement implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ \"D4.1 \\u2014 E1: Shared project views. Add to this plan's scope?\\nProject/branch/task: main, PLAN.md saved views, SCOPE EXPANSION, D2 schema approved (visibility column exists).\\nELI10: Today's plan gives each member private views. E1 lets a member publish a view to the whole project (\\\"Blocked\\\", \\\"This sprint\\\")...'. No index signature with a parameter of type 'string' was found on type '{ \"D4.1 \\u2014 E1: Shared project views. Add to this plan's scope?\\nProject/branch/task: main, PLAN.md saved views, SCOPE EXPANSION, D2 schema approved (visibility column exists).\\nELI10: Today's plan gives each member private views. E1 lets a member publish a view to the whole project (\\\"Blocked\\\", \\\"This sprint\\\")...'.": 1,
"test/ceo-mode-option.test.ts\tTS2322\tType '{ [x: string]: string; }' is not assignable to type '{ \"D2 \\u2014 Which review mode should govern this plan? (ledger row R1)\\nProject/branch/task: gstack-plan-count-w6cXCj on main, PLAN.md: saved project views.\\nELI10: The mode sets my posture for the rest of the review. Expansion means I pitch bigger versions and ask you about each. Selective means I keep your scope,...'. Property '\"D3.0 \\u2014 Seven expansion proposals are on the table. How should I walk them?\\nProject/branch/task: gstack-plan-count-w6cXCj on main, saved project views, SCOPE EXPANSION mode.\\nELI10: The proposals are E1 project-shared views, E2 default views, E3 stale-filter handling, E4 URL-addressable views, E5 pilot instrumentation, E6 dirty-state Update/Save-as-new, E7 delight pack (rename, duplicate, save nudge, shortcut, empty state, page title). Each is a separate scope call. I can ask one question per item (7 questions, each Add / Defer / Skip / Hold), or first propose a smaller set, or batch them into groups. Dependencies: E2's project default needs E1; E4 cross-member links need E1; E5 is what makes the pilot metric real for everything else.\\nStakes if we pick wrong: Per-item gives you full control at the cost of 7 prompts; batching is faster but risks lumping unrelated decisions together.\\nRecommendation: A because every proposal is independently shippable and this mode exists to let you weigh each one.\\nNote: options differ in kind, not coverage \\u2014 no completeness score.\\nNet: decision precision versus prompt count.\"' is missing in type '{ [x: string]: string; }' but required in type '{ \"D3.0 \\u2014 Seven expansion proposals are on the table. How should I walk them?\\nProject/branch/task: gstack-plan-count-w6cXCj on main, saved project views, SCOPE EXPANSION mode.\\nELI10: The proposals are E1 project-shared views, E2 default views, E3 stale-filter handling, E4 URL-addressable views, E5 pilot instr...'.": 1,
"test/ceo-mode-option.test.ts\tTS2345\tArgument of type '({ content?: undefined; isError?: undefined; kind: string; sessionId: string; timestamp: string; toolUseId: string; name: string; input: { questions: { header: string; question: string; options: { label: string; description: string; }[]; multiSelect: boolean; }[]; }; } | { ...; })[]' is not assignable to parameter of type 'readonly NativePublicToolEvent[]'. Type '{ content?: undefined; isError?: undefined; kind: string; sessionId: string; timestamp: string; toolUseId: string; name: string; input: { questions: { header: string; question: string; options: { label: string; description: string; }[]; multiSelect: boolean; }[]; }; } | { ...; }' is not assignable to type 'NativePublicToolEvent'. Type '{ content?: undefined; isError?: undefined; kind: string; sessionId: string; timestamp: string; toolUseId: string; name: string; input: { questions: { header: string; question: string; options: { label: string; description: string; }[]; multiSelect: boolean; }[]; }; }' is not assignable to type 'NativePublicToolEvent'. Types of property 'kind' are incompatible. Type 'string' is not assignable to type '\"result\" | \"use\"'.": 5,
"test/ceo-mode-option.test.ts\tTS2345\tArgument of type '({ isError?: undefined; content?: undefined; kind: string; sessionId: string; timestamp: string; toolUseId: string; name: string; input: { questions: { header: string; question: string; options: { label: string; description: string; }[]; multiSelect: boolean; }[]; }; } | { ...; })[]' is not assignable to parameter of type 'readonly NativePublicToolEvent[]'. Type '{ isError?: undefined; content?: undefined; kind: string; sessionId: string; timestamp: string; toolUseId: string; name: string; input: { questions: { header: string; question: string; options: { label: string; description: string; }[]; multiSelect: boolean; }[]; }; } | { ...; }' is not assignable to type 'NativePublicToolEvent'. Type '{ isError?: undefined; content?: undefined; kind: string; sessionId: string; timestamp: string; toolUseId: string; name: string; input: { questions: { header: string; question: string; options: { label: string; description: string; }[]; multiSelect: boolean; }[]; }; }' is not assignable to type 'NativePublicToolEvent'. Types of property 'kind' are incompatible. Type 'string' is not assignable to type '\"result\" | \"use\"'.": 5,
"test/ceo-mode-option.test.ts\tTS2345\tArgument of type '{ status: 'ready'; calls: ({ sessionId: string; toolUseId: string; questions: { question: string; header: string; multiSelect: boolean; options: { label: string; description: string; }[]; }[]; answered: boolean; failed: boolean; answers: { \"D3 \\u2014 Which review mode should govern this plan?\\nProject/branch/task: f...' is not assignable to parameter of type 'PlanCountTranscript'. Types of property 'calls' are incompatible. Type '({ sessionId: string; toolUseId: string; questions: { question: string; header: string; multiSelect: boolean; options: { label: string; description: string; }[]; }[]; answered: boolean; failed: boolean; answers: { ...; }; unansweredQuestionIndices: never[]; answeredAt: string; } | { ...; })[]' is not assignable to type 'NativePlanQuestionCall[]'. Type '{ sessionId: string; toolUseId: string; questions: { question: string; header: string; multiSelect: boolean; options: { label: string; description: string; }[]; }[]; answered: boolean; failed: boolean; answers: { ...; }; unansweredQuestionIndices: never[]; answeredAt: string; } | { ...; }' is not assignable to type 'NativePlanQuestionCall'. Type '{ sessionId: string; toolUseId: string; questions: { question: string; header: string; multiSelect: boolean; options: { label: string; description: string; }[]; }[]; answered: boolean; failed: boolean; answers: { \"D3 \\u2014 Which review mode should govern this plan?\\nProject/branch/task: fixture repo on main; review...' is not assignable to type 'NativePlanQuestionCall'. Types of property 'answers' are incompatible. Type '{ \"D3 \\u2014 Which review mode should govern this plan?\\nProject/branch/task: fixture repo on main; reviewing PLAN.md \\\"Add saved project views\\\" (per-member named filter+sort presets on a project task list).\\nELI10: The mode sets my posture for the rest of the review. It decides whether I push you to build a bigger...' is not assignable to type 'Record<string, string>'. Property '\"D4.1 \\u2014 E1: Store views using the task list's existing filter/sort serialization?\\nProject/branch/task: main; saved project views plan, SCOPE EXPANSION, proposal 1 of 6.\\nELI10: Your task list already turns filters and sort into some encoded shape (usually the URL query string). A saved view should persist exactly that shape, not a new hand-rolled JSON schema. Then saved views, URLs, and shared links all speak one language, and when you add a new filter next quarter, old views keep working without a migration.\\nStakes if we pick wrong: Two filter encodings drift apart; every new filter needs a stored-view migration; deep links (E2) and shared views (E3) need translation code.\\nRecommendation: Add because it is the cheapest item here (human ~0.5 d / CC ~15 min) and it is the foundation E2\\u2013E4 stand on.\\nCompleteness: A=10/10, B=5/10, C=3/10, D=n/a\\nNet: one canonical filter language now vs. a second schema you maintain forever.\"' is incompatible with index signature. Type 'undefined' is not assignable to type 'string'.": 1,
"test/ceo-mode-option.test.ts\tTS2741\tProperty '\"D3.0 \\u2014 Seven expansion proposals are on the table. How should I walk them?\\nProject/branch/task: gstack-plan-count-w6cXCj on main, saved project views, SCOPE EXPANSION mode.\\nELI10: The proposals are E1 project-shared views, E2 default views, E3 stale-filter handling, E4 URL-addressable views, E5 pilot instrumentation, E6 dirty-state Update/Save-as-new, E7 delight pack (rename, duplicate, save nudge, shortcut, empty state, page title). Each is a separate scope call. I can ask one question per item (7 questions, each Add / Defer / Skip / Hold), or first propose a smaller set, or batch them into groups. Dependencies: E2's project default needs E1; E4 cross-member links need E1; E5 is what makes the pilot metric real for everything else.\\nStakes if we pick wrong: Per-item gives you full control at the cost of 7 prompts; batching is faster but risks lumping unrelated decisions together.\\nRecommendation: A because every proposal is independently shippable and this mode exists to let you weigh each one.\\nNote: options differ in kind, not coverage \\u2014 no completeness score.\\nNet: decision precision versus prompt count.\"' is missing in type '{ [x: string]: string; }' but required in type '{ \"D3.0 \\u2014 Seven expansion proposals are on the table. How should I walk them?\\nProject/branch/task: gstack-plan-count-w6cXCj on main, saved project views, SCOPE EXPANSION mode.\\nELI10: The proposals are E1 project-shared views, E2 default views, E3 stale-filter handling, E4 URL-addressable views, E5 pilot instr...'.": 1,
"test/ceo-mode-option.test.ts\tTS2741\tProperty '\"D4.1 \\u2014 E1: Store views using the task list's existing filter/sort serialization?\\nProject/branch/task: main; saved project views plan, SCOPE EXPANSION, proposal 1 of 6.\\nELI10: Your task list already turns filters and sort into some encoded shape (usually the URL query string). A saved view should persist exactly that shape, not a new hand-rolled JSON schema. Then saved views, URLs, and shared links all speak one language, and when you add a new filter next quarter, old views keep working without a migration.\\nStakes if we pick wrong: Two filter encodings drift apart; every new filter needs a stored-view migration; deep links (E2) and shared views (E3) need translation code.\\nRecommendation: Add because it is the cheapest item here (human ~0.5 d / CC ~15 min) and it is the foundation E2\\u2013E4 stand on.\\nCompleteness: A=10/10, B=5/10, C=3/10, D=n/a\\nNet: one canonical filter language now vs. a second schema you maintain forever.\"' is missing in type '{ [x: string]: string; }' but required in type '{ \"D3 \\u2014 Which review mode should govern this plan?\\nProject/branch/task: fixture repo on main; reviewing PLAN.md \\\"Add saved project views\\\" (per-member named filter+sort presets on a project task list).\\nELI10: The mode sets my posture for the rest of the review. It decides whether I push you to build a bigger...'.": 1,
+2 -2
View File
@@ -31,9 +31,9 @@ You are running a ship-workflow plan completion audit. The base branch is `<base
setopt +o nomatch 2>/dev/null || true # zsh compat
BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-' | tr -cd 'a-zA-Z0-9._-')
REPO=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)")
_PLAN_SLUG=$(git remote get-url origin 2>/dev/null | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-' | tr -cd 'a-zA-Z0-9._-') || true
_PLAN_SLUG="${_PLAN_SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}"
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
_PLAN_SLUG=$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null | sed -n 's/^SLUG=//p') || true
_PLAN_SLUG="${_PLAN_SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}"
for PLAN_DIR in "$GSTACK_STATE_ROOT/projects/$_PLAN_SLUG" "$HOME/.claude/plans" "$HOME/.codex/plans" ".gstack/plans"; do
[ -d "$PLAN_DIR" ] || continue
PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$BRANCH" 2>/dev/null | head -1)
+66 -5
View File
@@ -9,6 +9,8 @@ import {
outermostProjectRoot,
resolveSlug,
NEEDS_NATIVE_SLUG_ON_WINDOWS,
slugCacheFile,
readVersionedSlugCache,
} from "../lib/bin-context";
const ROOT = path.resolve(import.meta.dir, "..");
@@ -90,7 +92,7 @@ describe("native slug fallback mirrors bin/gstack-slug", () => {
const key = path.join(home, "slug-cache", toMsysPath(cwd).replace(/\//g, "_"));
expect(fs.existsSync(key)).toBe(true);
// no trailing newline: gstack-slug writes with printf '%s'
expect(fs.readFileSync(key, "utf-8")).toBe(slug);
expect(fs.readFileSync(key, "utf-8")).toBe(`v2:${slug}`);
});
test("never returns the empty string", () => {
@@ -197,7 +199,7 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
expect(slugFromEnvironment(nativeHome(), siteSubdir)).toBe("loadout");
// The cache file itself must have been overwritten (self-healing).
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("loadout");
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("v2:loadout");
});
test("sticky cache (#2212): a cached identity that is NOT the old-bug shape survives", () => {
@@ -323,7 +325,7 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
expect(slugFromEnvironment(nativeHome(), repo)).toBe("garrytan-gstack");
// The cache file itself must have been overwritten (self-healing).
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("garrytan-gstack");
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("v2:garrytan-gstack");
});
test("package.json wrapper root (no .git): sticky basename slug is PRESERVED — heal is stray-repo-shape only", () => {
@@ -344,7 +346,7 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
fs.writeFileSync(cacheFile, "wrapperproj"); // legit sticky identity
expect(slugFromEnvironment(nativeHome(), inner)).toBe("wrapperproj"); // NOT healed to acme-web
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("wrapperproj");
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("v2:wrapperproj");
// The bash implementation agrees on the same fixture (own home, seeded cache).
if (HAS_BASH) {
@@ -370,7 +372,7 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
fs.writeFileSync(cacheFile, "stickyproj"); // pre-origin basename identity
expect(slugFromEnvironment(nativeHome(), repo)).toBe("stickyproj");
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("stickyproj");
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("v2:stickyproj");
});
test('hostile origin `url = ..` never becomes a dot slug — basename fallback (dot-only guard)', () => {
@@ -404,6 +406,65 @@ describe("walk-up parity with bin/gstack-slug (outermost project root)", () => {
expect(fs.existsSync(cacheFile)).toBe(false);
});
test("nested-group remotes (#3003): 2/3/4-segment ssh+https agree with bin/gstack-slug and the golden vectors", () => {
const vectors: Array<{ url: string; slug: string }> = JSON.parse(
fs.readFileSync(path.join(import.meta.dir, "fixtures", "remote-identity-vectors.json"), "utf-8"),
);
const urls = [
"https://github.com/garrytan/gstack.git",
"git@github.com:garrytan/gstack.git",
"https://gitlab.com/customer-a/product/repo.git",
"ssh://git@gitlab.com:2222/customer-a/product/repo.git",
"git@gitlab.example.com:group/sub/team/repo.git",
"https://gitlab.example.com/group/sub/team/repo",
];
for (const url of urls) {
const want = vectors.find((v) => v.url === url)!.slug;
const repo = fs.mkdtempSync(path.join(tmp, "nested-"));
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
spawnSync("git", ["-C", repo, "remote", "add", "origin", url], { timeout: 30_000 });
expectBoth(repo, want);
}
});
test("unversioned legacy cache of a 3-segment remote is upgraded once and rewritten as v2 (#3003)", () => {
const repo = path.join(tmp, "nested-upgrade");
fs.mkdirSync(repo, { recursive: true });
spawnSync("git", ["init", "-q", repo], { timeout: 30_000 });
spawnSync("git", ["-C", repo, "remote", "add", "origin", "git@gitlab.com:customer-a/product/repo.git"], { timeout: 30_000 });
const cacheFile = slugCacheFile(nativeHome(), repo);
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
fs.writeFileSync(cacheFile, "product-repo"); // what the pre-#3003 resolver cached
expect(slugFromEnvironment(nativeHome(), repo)).toBe("product-repo-5eca1fe041984543");
expect(fs.readFileSync(cacheFile, "utf-8")).toBe("v2:product-repo-5eca1fe041984543");
expect(readVersionedSlugCache(nativeHome(), repo)).toBe("product-repo-5eca1fe041984543");
if (HAS_BASH) {
const bashCache = path.join(tmp, "bash-home", ".gstack", "slug-cache", toMsysPath(repo).replace(/\//g, "_"));
fs.mkdirSync(path.dirname(bashCache), { recursive: true });
fs.writeFileSync(bashCache, "product-repo");
expect(bashSlug(repo)).toBe("product-repo-5eca1fe041984543");
expect(fs.readFileSync(bashCache, "utf-8")).toBe("v2:product-repo-5eca1fe041984543");
}
});
test("readVersionedSlugCache accepts only v2 entries with a usable value", () => {
const cwd = path.join(tmp, "cache-reader");
fs.mkdirSync(cwd);
const file = slugCacheFile(nativeHome(), cwd);
fs.mkdirSync(path.dirname(file), { recursive: true });
expect(readVersionedSlugCache(nativeHome(), cwd)).toBeUndefined();
for (const [content, want] of [
["legacy-unversioned", undefined],
["v2:", undefined],
["v2:..", undefined],
["v2:a/../b", "a..b"],
["v2:owner-repo", "owner-repo"],
] as const) {
fs.writeFileSync(file, content);
expect(readVersionedSlugCache(nativeHome(), cwd)).toBe(want);
}
});
test("outermostProjectRoot terminates on hostile path forms (dirname fixed points)", () => {
// Mirrors the windows-free-tests regression on the bash side: mixed-form
// paths must hit the dirname fixed point, not loop. A hang here would trip
+281
View File
@@ -0,0 +1,281 @@
/**
* /context-save + /context-restore project identity (#3003), end to end.
*
* Runs the REAL bash blocks extracted from the generated context-save and
* context-restore SKILL.md files (with ~/.claude/skills/gstack pointed at this
* checkout) inside fixture repos, so the checkpoint stamp, the restore
* classification and the helper modes are exercised together. The bug these
* pin: GitLab repos in different groups (a/product/repo, b/product/repo) shared
* one projects/product-repo/ bucket, and /context-restore in project B
* presented project A's checkpoint as the latest one, with nothing failing.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const SLUG_BIN = path.join(ROOT, 'bin', 'gstack-slug');
function bashBlockAfter(skillFile: string, heading: string, contains?: string): string {
const md = fs.readFileSync(path.join(ROOT, skillFile), 'utf-8');
const start = md.indexOf(heading);
if (start < 0) throw new Error(`${skillFile}: heading not found: ${heading}`);
const re = /```bash\n([\s\S]*?)```/g;
re.lastIndex = start;
for (let m = re.exec(md); m; m = re.exec(md)) {
if (!contains || m[1].includes(contains)) return m[1].replaceAll('~/.claude/skills/gstack', ROOT);
}
throw new Error(`${skillFile}: no bash block after ${heading}`);
}
const SAVE_PATH_BLOCK = bashBlockAfter('context-save/SKILL.md', '### Step 4: Write saved-context file').replace(
'TIMESTAMP=$(date +%Y%m%d-%H%M%S)',
'TIMESTAMP="$TEST_TIMESTAMP"',
);
const STAMP_BLOCK = bashBlockAfter('context-save/SKILL.md', '### Step 4: Write saved-context file', '--stamp-checkpoint');
const RESTORE_BLOCK = bashBlockAfter('context-restore/SKILL.md', '### Step 1: Find saved contexts');
let tmp: string;
let home: string;
function env(extra: Record<string, string> = {}): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) if (v !== undefined) out[k] = v;
delete out.GSTACK_PROJECT_SLUG;
delete out.GSTACK_STATE_ROOT;
delete out.GSTACK_STATE_DIR;
delete out.CURRENT_BRANCH;
return { ...out, HOME: tmp, GSTACK_HOME: home, ...extra };
}
function sh(script: string, cwd: string, extra: Record<string, string> = {}) {
const r = spawnSync('bash', ['-c', script], { cwd, env: env(extra), encoding: 'utf-8', timeout: 30_000 });
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
}
function repo(name: string, origin?: string): string {
const dir = path.join(tmp, name);
fs.mkdirSync(dir, { recursive: true });
for (const args of [['init', '-q', '-b', 'main'], ...(origin ? [['remote', 'add', 'origin', origin]] : [])]) {
const r = spawnSync('git', args, { cwd: dir, encoding: 'utf-8', timeout: 30_000 });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
}
return dir;
}
/** The context-save flow: path block, the agent's Write, then the stamp block. */
function save(cwd: string, title: string, timestamp: string, branch = 'main'): string {
const paths = sh(SAVE_PATH_BLOCK, cwd, { TITLE_RAW: title, TEST_TIMESTAMP: timestamp });
expect(paths.status).toBe(0);
const file = paths.stdout.match(/^FILE=(.+)$/m)![1];
fs.writeFileSync(file, `---\nstatus: in-progress\nbranch: ${branch}\ntimestamp: ${timestamp}\n---\n\n## Working on: ${title}\n`);
const stamp = sh(STAMP_BLOCK.replace('FILE="<the FILE path printed above>"', `FILE=${JSON.stringify(file)}`), cwd);
expect(stamp.stdout).toContain('STAMPED');
return file;
}
function restore(cwd: string, branch = 'main') {
const r = sh(RESTORE_BLOCK, cwd, { CURRENT_BRANCH: branch });
expect(r.status).toBe(0);
const lines = r.stdout.split('\n').filter(Boolean);
const candidates = lines.filter((l) => l.startsWith('/'));
return { out: r.stdout, lines, candidates, latest: candidates[0] };
}
function legacyCheckpoint(bucket: string, name: string, branch = 'main'): string {
const dir = path.join(home, 'projects', bucket, 'checkpoints');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, name);
fs.writeFileSync(file, `---\nstatus: in-progress\nbranch: ${branch}\n---\n\n## Working on: ${name}\n`);
return file;
}
beforeEach(() => {
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ctx-identity-')));
home = path.join(tmp, '.gstack');
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
describe('context-save stamps the project identity', () => {
test('checkpoint frontmatter gets the canonical remote and physical root; credentials never reach disk', () => {
const secret = ['sentinel', 'pw', '55120'].join('-');
const a = repo('a', `https://deploy-bot:${secret}@GitLab.com:8443/customer-a/product/repo.git`);
const file = save(a, 'auth refactor', '20261001-100000');
expect(file.startsWith(path.join(home, 'projects', 'product-repo-5eca1fe041984543', 'checkpoints'))).toBe(true);
const text = fs.readFileSync(file, 'utf-8');
const frontmatter = text.split('---')[1];
expect(frontmatter).toContain('\nremote: gitlab.com/customer-a/product/repo\n');
expect(frontmatter).toContain(`\nproject_root: ${a}\n`);
expect(text).toContain('## Working on: auth refactor');
const leaks = spawnSync('grep', ['-rl', secret, home], { encoding: 'utf-8', timeout: 10_000 });
expect(leaks.stdout).toBe('');
});
test('re-stamping replaces stale identity lines instead of duplicating them', () => {
const a = repo('a', 'git@gitlab.com:customer-a/product/repo.git');
const file = save(a, 'x', '20261001-100000');
fs.writeFileSync(file, fs.readFileSync(file, 'utf-8').replace('remote: gitlab.com/customer-a/product/repo', 'remote: bogus'));
const r = spawnSync('bash', [SLUG_BIN, '--stamp-checkpoint', file], { cwd: a, env: env(), encoding: 'utf-8', timeout: 30_000 });
expect(r.status).toBe(0);
const fm = fs.readFileSync(file, 'utf-8').split('---')[1];
expect(fm.match(/^remote:/gm)?.length).toBe(1);
expect(fm.match(/^project_root:/gm)?.length).toBe(1);
expect(fm).toContain('remote: gitlab.com/customer-a/product/repo');
});
});
describe('context-restore never presents another project checkpoint as latest', () => {
test('nested groups: B restores after A saved — A is not presented, the old shared bucket is reported', () => {
const a = repo('a', 'git@gitlab.com:customer-a/product/repo.git');
const b = repo('b', 'https://gitlab.com/customer-b/product/repo.git');
// Before the fix both projects filed into projects/product-repo/.
legacyCheckpoint('product-repo', '20260901-090000-old-a-work.md');
const aFile = save(a, 'customer a migration', '20261001-120000');
const r = restore(b);
expect(r.out).not.toContain(aFile);
expect(r.out).not.toContain('old-a-work');
expect(r.out).toContain('NO_CHECKPOINTS');
expect(r.out).toContain('PROJECT_IDENTITY: gitlab.com/customer-b/product/repo');
expect(r.out).toContain('LEGACY_BUCKET: projects/product-repo holds 1 earlier checkpoint(s)');
const own = restore(a);
expect(own.latest).toBe(aFile);
});
test('two projects still sharing one bucket: the foreign newer checkpoint is never "latest"', () => {
// github.com/product/repo and gitlab.com/product/repo are 2-segment remotes
// and so keep the same slug (product-repo); identity tells them apart.
const gh = repo('gh', 'https://github.com/product/repo.git');
const gl = repo('gl', 'git@gitlab.com:product/repo.git');
const own = save(gl, 'gitlab work', '20261001-090000');
const foreign = save(gh, 'github work', '20261001-110000');
expect(path.dirname(own)).toBe(path.dirname(foreign));
const r = restore(gl);
expect(r.latest).toBe(own);
expect(r.candidates).not.toContain(foreign);
expect(r.lines).toContain(`FOREIGN ${foreign}`);
expect(r.out).toContain('SHARED_BUCKET: 1 checkpoint(s) here were saved by another project');
// With no checkpoint of its own, gh's view of gl's bucket shows nothing as latest.
fs.rmSync(foreign);
const other = restore(gh);
expect(other.latest).toBeUndefined();
expect(other.out).toContain('NO_VERIFIED_CHECKPOINTS');
expect(other.lines).toContain(`FOREIGN ${own}`);
});
test('unstamped checkpoints: trusted in an unshared bucket, UNVERIFIED once another project wrote there', () => {
const gh = repo('gh', 'https://github.com/product/repo.git');
const gl = repo('gl', 'git@gitlab.com:product/repo.git');
const old = legacyCheckpoint('product-repo', '20260901-090000-pre-stamp.md');
expect(restore(gh).latest).toBe(old);
const foreign = save(gl, 'gitlab work', '20261001-110000');
const r = restore(gh);
expect(r.latest).toBeUndefined();
expect(r.lines).toContain(`UNVERIFIED ${old}`);
expect(r.lines).toContain(`FOREIGN ${foreign}`);
});
test('same remote, different checkout (workspace handoff): restorable, with ROOT_DIFFERS as info', () => {
const first = repo('workspace-1', 'git@github.com:acme/app.git');
const second = repo('workspace-2', 'https://github.com/acme/app');
const file = save(first, 'handoff', '20261001-100000', 'feat-x');
const r = restore(second, 'main');
expect(r.latest).toBe(file);
expect(r.lines).toContain(`ROOT_DIFFERS ${file}`);
expect(r.out).not.toContain('FOREIGN');
});
test('no remote: identity is the project root', () => {
const one = repo('solo');
const file = save(one, 'local only', '20261001-100000');
expect(fs.readFileSync(file, 'utf-8')).not.toMatch(/^remote:/m);
expect(restore(one).latest).toBe(file);
const elsewhere = repo('elsewhere');
const r = sh(RESTORE_BLOCK, elsewhere, { CURRENT_BRANCH: 'main', GSTACK_PROJECT_SLUG: 'solo' });
expect(r.stdout).toContain('NO_VERIFIED_CHECKPOINTS');
expect(r.stdout).toContain(`FOREIGN ${file}`);
});
});
describe('gstack-slug --adopt-legacy', () => {
function adopt(cwd: string, ...args: string[]) {
const r = spawnSync('bash', [SLUG_BIN, '--adopt-legacy', ...args], {
cwd,
env: env(),
encoding: 'utf-8',
timeout: 30_000,
stdio: ['ignore', 'pipe', 'pipe'],
});
return { stdout: r.stdout ?? '', status: r.status ?? -1 };
}
test('lists the old bucket with labels and copies only what is named; foreign and traversal refused', () => {
const a = repo('a', 'git@gitlab.com:customer-a/product/repo.git');
const b = repo('b', 'git@gitlab.com:customer-b/product/repo.git');
const oldBucket = path.join(home, 'projects', 'product-repo');
const unstamped = legacyCheckpoint('product-repo', '20260901-090000-unknown.md');
// Stamped checkpoints in the old bucket (written by A and B), via the real stamp.
const mine = legacyCheckpoint('product-repo', '20260902-090000-mine.md');
const theirs = legacyCheckpoint('product-repo', '20260903-090000-theirs.md');
spawnSync('bash', [SLUG_BIN, '--stamp-checkpoint', mine], { cwd: a, env: env(), timeout: 30_000 });
spawnSync('bash', [SLUG_BIN, '--stamp-checkpoint', theirs], { cwd: b, env: env(), timeout: 30_000 });
fs.writeFileSync(path.join(oldBucket, 'question-preferences.json'), '{"review-finding-fix":"never-ask"}');
fs.writeFileSync(path.join(oldBucket, 'learnings.jsonl'), '{"x":1}\n');
const before = spawnSync('bash', ['-c', 'find . -type f | sort | xargs cat | cksum'], { cwd: oldBucket, encoding: 'utf-8', timeout: 10_000 }).stdout;
const list = adopt(a);
expect(list.status).toBe(0);
expect(list.stdout).toContain('checkpoints/20260902-090000-mine.md [checkpoint: matching this project]');
expect(list.stdout).toContain('checkpoints/20260903-090000-theirs.md [checkpoint: another project (not copyable)]');
expect(list.stdout).toContain('checkpoints/20260901-090000-unknown.md [checkpoint: unknown project');
expect(list.stdout).toContain('question-preferences.json [no project identity');
expect(list.stdout).toContain('Nothing copied.');
const newBucket = path.join(home, 'projects', 'product-repo-5eca1fe041984543');
expect(fs.existsSync(path.join(newBucket, 'question-preferences.json'))).toBe(false);
const copy = adopt(
a,
'--copy',
'checkpoints/20260902-090000-mine.md',
'question-preferences.json',
'checkpoints/20260903-090000-theirs.md',
'../product-repo/learnings.jsonl',
);
expect(copy.stdout).toContain('COPIED checkpoints/20260902-090000-mine.md');
expect(copy.stdout).toContain('COPIED question-preferences.json');
expect(copy.stdout).toContain('SKIPPED checkpoints/20260903-090000-theirs.md');
expect(copy.stdout).toContain('REFUSED ../product-repo/learnings.jsonl');
expect(copy.status).toBe(1);
expect(fs.readFileSync(path.join(newBucket, 'question-preferences.json'), 'utf-8')).toContain('never-ask');
expect(fs.existsSync(path.join(newBucket, 'checkpoints', '20260903-090000-theirs.md'))).toBe(false);
expect(fs.existsSync(path.join(newBucket, 'learnings.jsonl'))).toBe(false);
expect(fs.existsSync(path.join(newBucket, 'checkpoints', path.basename(unstamped)))).toBe(false);
// Never overwrites, never touches the old bucket.
const again = adopt(a, '--copy', 'question-preferences.json');
expect(again.stdout).toContain('SKIPPED question-preferences.json');
const after = spawnSync('bash', ['-c', 'find . -type f | sort | xargs cat | cksum'], { cwd: oldBucket, encoding: 'utf-8', timeout: 10_000 }).stdout;
expect(after).toBe(before);
// The adopted checkpoint is now A's latest on restore.
expect(restore(a).latest).toBe(path.join(newBucket, 'checkpoints', '20260902-090000-mine.md'));
});
test('a project whose slug did not change has nothing to adopt', () => {
const gh = repo('gh', 'https://github.com/acme/app.git');
const r = adopt(gh);
expect(r.status).toBe(0);
expect(r.stdout).toContain("Nothing to adopt: this project's slug (acme-app) did not change.");
});
});
+164
View File
@@ -0,0 +1,164 @@
[
{
"url": "https://github.com/garrytan/gstack.git",
"canonical": "github.com/garrytan/gstack",
"slug": "garrytan-gstack",
"legacy_slug": "garrytan-gstack"
},
{
"url": "https://github.com/garrytan/gstack",
"canonical": "github.com/garrytan/gstack",
"slug": "garrytan-gstack",
"legacy_slug": "garrytan-gstack"
},
{
"url": "git@github.com:garrytan/gstack.git",
"canonical": "github.com/garrytan/gstack",
"slug": "garrytan-gstack",
"legacy_slug": "garrytan-gstack"
},
{
"url": "ssh://git@github.com/garrytan/gstack.git",
"canonical": "github.com/garrytan/gstack",
"slug": "garrytan-gstack",
"legacy_slug": "garrytan-gstack"
},
{
"url": "https://GitHub.COM/garrytan/gstack/",
"canonical": "github.com/garrytan/gstack",
"slug": "httpsGitHub.COMgarrytangstack",
"legacy_slug": "httpsGitHub.COMgarrytangstack"
},
{
"url": "https://gitlab.com/customer-a/product/repo.git",
"canonical": "gitlab.com/customer-a/product/repo",
"slug": "product-repo-5eca1fe041984543",
"legacy_slug": "product-repo"
},
{
"url": "git@gitlab.com:customer-a/product/repo.git",
"canonical": "gitlab.com/customer-a/product/repo",
"slug": "product-repo-5eca1fe041984543",
"legacy_slug": "product-repo"
},
{
"url": "ssh://git@gitlab.com:2222/customer-a/product/repo.git",
"canonical": "gitlab.com/customer-a/product/repo",
"slug": "product-repo-5eca1fe041984543",
"legacy_slug": "product-repo"
},
{
"url": "https://oauth2:PASSWORD@GitLab.com:8443/customer-a/product/repo.git",
"canonical": "gitlab.com/customer-a/product/repo",
"slug": "product-repo-5eca1fe041984543",
"legacy_slug": "product-repo"
},
{
"url": "https://gitlab.com/customer-b/product/repo.git",
"canonical": "gitlab.com/customer-b/product/repo",
"slug": "product-repo-3c13cab341f442f8",
"legacy_slug": "product-repo"
},
{
"url": "git@gitlab.example.com:group/sub/team/repo.git",
"canonical": "gitlab.example.com/group/sub/team/repo",
"slug": "team-repo-da3955fc2f81a1a1",
"legacy_slug": "team-repo"
},
{
"url": "https://gitlab.example.com/group/sub/team/repo",
"canonical": "gitlab.example.com/group/sub/team/repo",
"slug": "team-repo-da3955fc2f81a1a1",
"legacy_slug": "team-repo"
},
{
"url": "https://dev.azure.com/org/project/_git/repo",
"canonical": "dev.azure.com/org/project/repo",
"slug": "project-repo-a4383ea5249af29a",
"legacy_slug": "_git-repo"
},
{
"url": "https://org@dev.azure.com/org/project/_git/repo",
"canonical": "dev.azure.com/org/project/repo",
"slug": "project-repo-a4383ea5249af29a",
"legacy_slug": "_git-repo"
},
{
"url": "git@ssh.dev.azure.com:v3/org/project/repo",
"canonical": "dev.azure.com/org/project/repo",
"slug": "project-repo-a4383ea5249af29a",
"legacy_slug": "project-repo"
},
{
"url": "https://org.visualstudio.com/project/_git/repo",
"canonical": "dev.azure.com/org/project/repo",
"slug": "project-repo-a4383ea5249af29a",
"legacy_slug": "_git-repo"
},
{
"url": "https://org.visualstudio.com/DefaultCollection/project/_git/repo",
"canonical": "dev.azure.com/org/project/repo",
"slug": "project-repo-a4383ea5249af29a",
"legacy_slug": "_git-repo"
},
{
"url": "org@vs-ssh.visualstudio.com:v3/org/project/repo",
"canonical": "dev.azure.com/org/project/repo",
"slug": "project-repo-a4383ea5249af29a",
"legacy_slug": "project-repo"
},
{
"url": "https://bitbucket.example.com/scm/proj/repo.git",
"canonical": "bitbucket.example.com/proj/repo",
"slug": "proj-repo",
"legacy_slug": "proj-repo"
},
{
"url": "ssh://git@bitbucket.example.com:7999/proj/repo.git",
"canonical": "bitbucket.example.com/proj/repo",
"slug": "proj-repo",
"legacy_slug": "proj-repo"
},
{
"url": "/srv/git/team/product/repo.git",
"canonical": "/srv/git/team/product/repo",
"slug": "product-repo",
"legacy_slug": "product-repo"
},
{
"url": "file:///srv/git/repo.git",
"canonical": "/srv/git/repo",
"slug": "git-repo",
"legacy_slug": "git-repo"
},
{
"url": "../sibling-repo",
"canonical": "../sibling-repo",
"slug": "..sibling-repo",
"legacy_slug": "..sibling-repo"
},
{
"url": "..",
"canonical": "..",
"slug": "",
"legacy_slug": ""
},
{
"url": "https://host/onlyrepo.git",
"canonical": "host/onlyrepo",
"slug": "host-onlyrepo",
"legacy_slug": "host-onlyrepo"
},
{
"url": "C:/repos/a/b/c.git",
"canonical": "C:/repos/a/b/c",
"slug": "b-c",
"legacy_slug": "b-c"
},
{
"url": "https://[2001:db8::1]:8443/grp/sub/repo.git",
"canonical": "[2001:db8::1]/grp/sub/repo",
"slug": "sub-repo-4278116e81ebff80",
"legacy_slug": "sub-repo"
}
]
+1
View File
@@ -1938,6 +1938,7 @@ describe('BENEFITS_FROM resolver', () => {
fs.copyFileSync(path.join(ROOT, 'bin/gstack-slug'), helper);
fs.chmodSync(helper, 0o755);
fs.copyFileSync(path.join(ROOT, 'bin/gstack-state-root.sh'), path.join(path.dirname(helper), 'gstack-state-root.sh'));
fs.copyFileSync(path.join(ROOT, 'bin/gstack-remote-identity.sh'), path.join(path.dirname(helper), 'gstack-remote-identity.sh'));
const expected = path.join(home, '.gstack/projects/canonical-override/session-unknown-design-current.md');
const wrong = path.join(home, '.gstack/projects/project/session-unknown-design-wrong.md');
for (const file of [expected, wrong]) {
+2 -2
View File
@@ -137,7 +137,7 @@ describe('gstack-slug — outermost project-root resolution', () => {
// The cache file itself must have been overwritten (self-healing).
const cachedAfter = fs.readFileSync(cacheFile, 'utf8').trim();
expect(cachedAfter).toBe('loadout');
expect(cachedAfter).toBe('v2:loadout');
});
// AC-3: no regression — cwd IS the project root.
@@ -213,7 +213,7 @@ describe('gstack-slug — outermost project-root resolution', () => {
expect(result.status).toBe(0);
// Target key got self-healed.
expect(fs.readFileSync(path.join(cacheDir, targetKey), 'utf8').trim()).toBe('loadout');
expect(fs.readFileSync(path.join(cacheDir, targetKey), 'utf8').trim()).toBe('v2:loadout');
// Unrelated key is untouched.
expect(fs.readFileSync(unrelatedFile, 'utf8').trim()).toBe('unrelated-value-must-survive');
});
+108 -4
View File
@@ -216,7 +216,7 @@ describe('gstack-slug ↔ remote-slug parity', () => {
expect(gstack.status).toBe(0);
expect(slugOf(gstack)).toBe('garrytan-gstack');
// The cache file itself must have been overwritten (self-healing).
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('garrytan-gstack');
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('v2:garrytan-gstack');
});
test('hostile origin `url = ..` cannot become a ".." slug — basename fallback holds', () => {
@@ -230,7 +230,7 @@ describe('gstack-slug ↔ remote-slug parity', () => {
expect(slugOf(r)).toBe('dotty');
// The cache must hold the healed value, never the dot slug.
const cacheFile = path.join(tmpHome, '.gstack', 'slug-cache', encodedCacheKey(repo));
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('dotty');
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('v2:dotty');
});
test('package.json wrapper root (no .git): sticky basename slug is PRESERVED — heal is stray-repo-shape only', () => {
@@ -252,7 +252,7 @@ describe('gstack-slug ↔ remote-slug parity', () => {
const r = runSlug(inner, tmpHome);
expect(r.status).toBe(0);
expect(slugOf(r)).toBe('wrapperproj'); // NOT healed to acme-web
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('wrapperproj');
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('v2:wrapperproj');
});
test('sticky identity preserved (#2212): repo that adopted a remote after first use is NOT healed', () => {
@@ -267,6 +267,110 @@ describe('gstack-slug ↔ remote-slug parity', () => {
const gstack = runSlug(repo, tmpHome);
expect(gstack.status).toBe(0);
expect(slugOf(gstack)).toBe('stickyproj');
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('stickyproj');
expect(fs.readFileSync(cacheFile, 'utf8').trim()).toBe('v2:stickyproj');
});
// ── Nested-group remotes (#3003) ─────────────────────────────────────────
const VECTORS: Array<{ url: string; slug: string }> = JSON.parse(
fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'remote-identity-vectors.json'), 'utf8'),
);
const vectorSlug = (url: string): string => VECTORS.find((v) => v.url === url)!.slug;
test('2/3/4-segment ssh+https remotes: both bins agree with the golden vectors', () => {
const urls = [
'https://github.com/garrytan/gstack.git',
'git@github.com:garrytan/gstack.git',
'https://gitlab.com/customer-a/product/repo.git',
'git@gitlab.com:customer-a/product/repo.git',
'ssh://git@gitlab.com:2222/customer-a/product/repo.git',
'https://gitlab.com/customer-b/product/repo.git',
'git@gitlab.example.com:group/sub/team/repo.git',
'https://gitlab.example.com/group/sub/team/repo',
];
urls.forEach((url, i) => {
const repo = makeRepo(path.join(fixtures, `nested-${i}`), url);
expectParity(repo, tmpHome, vectorSlug(url));
});
// Same nested repo over ssh, https and ssh:// with a port → one bucket;
// a sibling group's repo with the same last two segments → a different one.
expect(vectorSlug('git@gitlab.com:customer-a/product/repo.git'))
.toBe(vectorSlug('ssh://git@gitlab.com:2222/customer-a/product/repo.git'));
expect(vectorSlug('https://gitlab.com/customer-b/product/repo.git'))
.not.toBe(vectorSlug('https://gitlab.com/customer-a/product/repo.git'));
});
test('pre-populated unversioned cache of a nested repo moves once, prints one notice, then stays git-free', () => {
const repo = makeRepo(path.join(fixtures, 'nested-cached'), 'git@gitlab.com:customer-a/product/repo.git');
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, encodedCacheKey(repo));
fs.writeFileSync(cacheFile, 'product-repo'); // pre-#3003 sticky value
fs.mkdirSync(path.join(tmpHome, '.gstack', 'projects', 'product-repo', 'checkpoints'), { recursive: true });
const first = runSlug(repo, tmpHome);
expect(first.status).toBe(0);
expect(slugOf(first)).toBe('product-repo-5eca1fe041984543');
expect(fs.readFileSync(cacheFile, 'utf8')).toBe('v2:product-repo-5eca1fe041984543');
expect(first.stderr).toContain(
'gstack now files this project under projects/product-repo-5eca1fe041984543; earlier data is still in projects/product-repo',
);
// The old bucket is left in place, never moved or deleted.
expect(fs.existsSync(path.join(tmpHome, '.gstack', 'projects', 'product-repo', 'checkpoints'))).toBe(true);
// Second resolve: a v2 cache hit — no notice and no remote lookup.
const shimDir = path.join(fixtures, 'git-shim');
fs.mkdirSync(shimDir, { recursive: true });
const realGit = spawnSync('bash', ['-c', 'command -v git'], { encoding: 'utf8', timeout: 10_000 }).stdout.trim();
const log = path.join(fixtures, 'git-calls.log');
fs.writeFileSync(path.join(shimDir, 'git'), `#!/usr/bin/env bash\necho "$*" >> "${log}"\nexec "${realGit}" "$@"\n`, { mode: 0o755 });
const second = spawnSync('bash', [SLUG_SCRIPT], {
cwd: repo,
env: { ...baseEnv(tmpHome), PATH: `${shimDir}:${process.env.PATH}` },
encoding: 'utf8',
timeout: 10_000,
});
expect(slugOf(second)).toBe('product-repo-5eca1fe041984543');
expect(second.stderr).toBe('');
const calls = fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '';
expect(calls).not.toContain('remote get-url');
});
test('unversioned sticky identity of a nested repo that is NOT the old last-two slug is kept (#2212)', () => {
const repo = makeRepo(path.join(fixtures, 'stickynested'), 'https://gitlab.com/customer-a/product/repo.git');
const cacheDir = path.join(tmpHome, '.gstack', 'slug-cache');
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, encodedCacheKey(repo));
fs.writeFileSync(cacheFile, 'stickynested');
const r = runSlug(repo, tmpHome);
expect(slugOf(r)).toBe('stickynested');
expect(fs.readFileSync(cacheFile, 'utf8')).toBe('v2:stickynested');
expect(r.stderr).toBe('');
});
test('--identity prints the canonical remote without credentials, and nothing on disk holds them', () => {
const user = 'ci-bot';
const secret = ['sentinel', 'tok', '81274'].join('-');
const repo = makeRepo(path.join(fixtures, 'creds'), `https://${user}:${secret}@GitLab.com:8443/customer-a/product/repo.git`);
const r = spawnSync('bash', [SLUG_SCRIPT, '--identity'], { cwd: repo, env: baseEnv(tmpHome), encoding: 'utf8', timeout: 10_000 });
expect(r.status).toBe(0);
expect(r.stdout).toContain('PROJECT_REMOTE=gitlab.com/customer-a/product/repo\n');
expect(r.stdout).toContain('SLUG=product-repo-5eca1fe041984543\n');
expect(r.stdout).toContain('LEGACY_SLUG=product-repo\n');
expect(r.stdout + r.stderr).not.toContain(secret);
const stateFiles = spawnSync('grep', ['-rl', secret, path.join(tmpHome, '.gstack')], { encoding: 'utf8', timeout: 10_000 });
expect(stateFiles.stdout).toBe('');
});
test('GSTACK_PROJECT_SLUG of "." or ".." is ignored instead of escaping projects/', () => {
const repo = makeRepo(path.join(fixtures, 'pinned'), 'https://github.com/acme/pinned.git');
for (const pin of ['.', '..', '/..']) {
const r = spawnSync('bash', [SLUG_SCRIPT], {
cwd: repo,
env: { ...baseEnv(tmpHome), GSTACK_PROJECT_SLUG: pin },
encoding: 'utf8',
timeout: 10_000,
});
expect(slugOf(r)).toBe('acme-pinned');
}
});
});
+1
View File
@@ -53,6 +53,7 @@ export function createHealthEvalFixture(dir: string, repoRoot: string): HealthEv
fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-slug'), path.join(dir, 'bin', 'gstack-slug'));
fs.chmodSync(path.join(dir, 'bin', 'gstack-slug'), 0o755);
fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-state-root.sh'), path.join(dir, 'bin', 'gstack-state-root.sh'));
fs.copyFileSync(path.join(repoRoot, 'bin', 'gstack-remote-identity.sh'), path.join(dir, 'bin', 'gstack-remote-identity.sh'));
// Only redirect installed paths. The workflow and score/history rules come
// from the real generated skill, without its unrelated shared preamble.
+12 -12
View File
@@ -27,7 +27,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
'scripts/discover-skills.ts', 'hosts/claude.ts', 'hosts/index.ts', 'hosts/define-host.ts',
'ship/sections/review-army.md*', 'ship/sections/adversarial.md*', 'ship/sections/manifest.json',
'review/checklist.md', 'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree',
'bin/gstack-slug', 'bin/gstack-config', 'bin/gstack-brain-enqueue',
'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-config', 'bin/gstack-brain-enqueue',
'lib/review-evidence.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts',
'test/helpers/ship-skip-actor.ts',
'test/helpers/scratch-repo.ts',
@@ -323,7 +323,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
'test/fixtures/autoplan-public-narration-ad.json',
'test/fixtures/auto-decide-completed-mode-f359.json',
'test/helpers/plan-count-transcript.ts', 'test/fixtures/plan-count-cross-cwd-ancestry-0bcd.json',
'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'bin/gstack-session-kind', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'plan-ceo-review/**', 'bin/gstack-question-preference', 'bin/gstack-config', 'bin/gstack-slug', 'hosts/claude/hooks/question-preference-hook.ts', 'hosts/claude/hooks/spawned-directive.ts', 'lib/is-conductor.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/skill-e2e-auto-decide-preserved.test.ts', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/native-auto-decide.ts', 'test/fixtures/auto-decide-current-declaration-6aef.json', 'test/fixtures/auto-decide-explanatory-mode-043a.json', 'test/fixtures/auto-decide-explanatory-mode-749df.json', 'test/fixtures/auto-decide-structured-77.json', 'test/helpers/auto-decision-state.ts', 'test/fixtures/auto-decide-state-cab3.json', 'bin/gstack-question-log', 'test/helpers/fake-plan-seed.ts', 'test/helpers/plan-seed-submission.ts', 'test/fixtures/plan-seed-cli.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/pty-screen.ts', 'test/fixtures/pty-screen/**',
'test/fixtures/auto-decide-saved-ai.json', 'test/fixtures/auto-decide-retry-ai.json','bin/gstack-skill-start', 'bin/gstack-skill-end', 'bin/gstack-session-kind', 'scripts/resolvers/question-tuning.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'plan-ceo-review/**', 'bin/gstack-question-preference', 'bin/gstack-config', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'hosts/claude/hooks/question-preference-hook.ts', 'lib/bin-context.ts', 'lib/remote-identity.ts', 'hosts/claude/hooks/spawned-directive.ts', 'lib/is-conductor.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/fixtures/design-ui-boxed-question.json', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/pty-trust-dialog.ts', 'test/skill-e2e-auto-decide-preserved.test.ts', 'test/fixtures/eng-d2-truncated-border-0bcd.json', 'test/fixtures/eng-d1-clipped-elision-1579.json', 'test/fixtures/eng-d2-planning-prelude-4d.json', 'test/fixtures/ceo-approach-z-call.json', 'test/fixtures/ceo-approach-z-screen.txt', 'test/helpers/native-auto-decide.ts', 'test/fixtures/auto-decide-current-declaration-6aef.json', 'test/fixtures/auto-decide-explanatory-mode-043a.json', 'test/fixtures/auto-decide-explanatory-mode-749df.json', 'test/fixtures/auto-decide-structured-77.json', 'test/helpers/auto-decision-state.ts', 'test/fixtures/auto-decide-state-cab3.json', 'bin/gstack-question-log', 'test/helpers/fake-plan-seed.ts', 'test/helpers/plan-seed-submission.ts', 'test/fixtures/plan-seed-cli.ts', 'test/fixtures/native-auto-decide-ag.json', 'test/fixtures/eng-seeded-completion-ai.json', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/pty-screen.ts', 'test/fixtures/pty-screen/**',
'test/helpers/plan-count-fixture.ts',
'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/fixtures/auto-decide-mode-selector-749df.json', 'lib/redact-engine.ts', 'lib/redact-patterns.ts', 'test/helpers/ceo-finding-fixture.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'hosts/claude/hooks/hook-log.ts'],
@@ -370,7 +370,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
"scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts",
'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/ceo-finding-fixture.ts', 'test/helpers/plan-review-cases.ts', 'test/helpers/plan-review-board-feedback.ts', 'test/fixtures/design-board-questions.json', 'test/fixtures/design-outside-voices-question.json', 'design/src/daemon-state.ts', 'design/src/daemon.ts', 'design/test/daemon-tests-fixtures.ts', 'design/src/daemon-client.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-paths', 'bin/gstack-slug', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts'],
'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/ceo-finding-fixture.ts', 'test/helpers/plan-review-cases.ts', 'test/helpers/plan-review-board-feedback.ts', 'test/fixtures/design-board-questions.json', 'test/fixtures/design-outside-voices-question.json', 'design/src/daemon-state.ts', 'design/src/daemon.ts', 'design/test/daemon-tests-fixtures.ts', 'design/src/daemon-client.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-paths', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts'],
'tpa-present': [ 'scripts/resolvers/third-party-actions.ts', 'ship/SKILL.md.tmpl', 'ship/sections/apple-release.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-third-party-actions.test.ts', 'test/helpers/third-party-actions.ts',
'lib/eval-model.ts'
],
@@ -397,7 +397,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
// the selector sets GSTACK_CARVE_SKILL=<name> to scope cost to the changed
// skill (D-CODEX A). Touching the registry/helper or sections.ts runs all.
'carve-section-loading': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts',
'bin/gstack-slug', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue',
'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-config', 'bin/gstack-brain-enqueue',
'test/fixtures/autoplan-amend-input-77.json',
'test/fixtures/autoplan-phase-handoff-6714.json','scripts/resolvers/learnings.ts',
@@ -537,7 +537,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
'test/fixtures/plan-count-crop-ak.json',
'test/fixtures/plan-count-quoted-frame-ak.json',
'docs/askuserquestion-split.md', 'bin/gstack-slug', 'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/ceo-finding-fixture.ts', 'test/helpers/owned-claude-transcript.ts', 'test/fixtures/webfetch-permission.json', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'test/helpers/plan-review-decisions.ts', 'test/helpers/plan-review-cases.ts', 'test/helpers/llm-judge.ts', 'lib/eval-model.ts', 'test/skill-e2e-plan-decision-classification.test.ts', 'test/fixtures/plan-decision-classification.ts', 'test/helpers/ceo-split-question-policy.ts', 'test/fixtures/ceo-split-actor-6aef.json', 'test/helpers/ceo-mode-option.ts', 'test/fixtures/ceo-split-collection-0bcd.json', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts'],
'docs/askuserquestion-split.md', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/ceo-finding-fixture.ts', 'test/helpers/owned-claude-transcript.ts', 'test/fixtures/webfetch-permission.json', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'test/helpers/plan-review-decisions.ts', 'test/helpers/plan-review-cases.ts', 'test/helpers/llm-judge.ts', 'lib/eval-model.ts', 'test/skill-e2e-plan-decision-classification.test.ts', 'test/fixtures/plan-decision-classification.ts', 'test/helpers/ceo-split-question-policy.ts', 'test/fixtures/ceo-split-actor-6aef.json', 'test/helpers/ceo-mode-option.ts', 'test/fixtures/ceo-split-collection-0bcd.json', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts'],
// /setup-gbrain Path 4 (Remote MCP) — happy + bad-token end-to-end via
// Agent SDK. Gate-tier (deterministic stub server, fixed inputs); fires
@@ -675,16 +675,16 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
// Session Intelligence (timeline, context recovery, /context-save + /context-restore)
'timeline-event-flow': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-timeline-log', 'bin/gstack-timeline-read', 'test/skill-e2e-session-intelligence.test.ts'],
'context-recovery-artifacts': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'scripts/resolvers/preamble.ts', 'bin/gstack-timeline-log', 'bin/gstack-slug', 'learn/**', 'test/skill-e2e-session-intelligence.test.ts'],
'context-save-writes-file': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'context-save/**', 'bin/gstack-slug', 'test/skill-e2e-session-intelligence.test.ts'],
'context-restore-loads-latest': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'context-restore/**', 'bin/gstack-slug', 'test/skill-e2e-session-intelligence.test.ts'],
'context-recovery-artifacts': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'scripts/resolvers/preamble.ts', 'bin/gstack-timeline-log', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'learn/**', 'test/skill-e2e-session-intelligence.test.ts'],
'context-save-writes-file': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'context-save/**', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'test/skill-e2e-session-intelligence.test.ts'],
'context-restore-loads-latest': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'context-restore/**', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'test/skill-e2e-session-intelligence.test.ts'],
// Context skills E2E (live-fire, Skill-tool routing path) — see
// test/skill-e2e-context-skills.test.ts. These are periodic-tier because
// each one spawns claude -p and costs ~$0.20-$0.40. Collectively they
// verify the thing the /checkpoint → /context-save rename was for.
'context-save-routing': [ 'context-save/**', 'scripts/resolvers/preamble.ts', 'test/skill-e2e-context-skills.test.ts'],
'context-save-then-restore-roundtrip': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'context-save/**', 'context-restore/**', 'bin/gstack-slug', 'test/skill-e2e-context-skills.test.ts'],
'context-save-then-restore-roundtrip': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'context-save/**', 'context-restore/**', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'test/skill-e2e-context-skills.test.ts'],
'context-restore-fragment-match': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts'],
'context-restore-empty-state': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts'],
'context-restore-list-delegates': [ 'context-restore/**', 'test/skill-e2e-context-skills.test.ts'],
@@ -731,7 +731,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
'plan-eng-review/**', 'plan-ceo-review/**', 'hosts/claude.ts', 'hosts/define-host.ts',
'scripts/gen-skill-docs.ts', 'scripts/resolvers/index.ts', 'scripts/resolvers/sections.ts',
'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/constants.ts',
'bin/gstack-config', 'bin/gstack-codex-probe', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-wtree', 'bin/gstack-brain-enqueue', 'test/helpers/session-runner.ts',
'bin/gstack-config', 'bin/gstack-codex-probe', 'bin/gstack-review-log', 'bin/gstack-review-read', 'lib/review-evidence.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'bin/gstack-wtree', 'bin/gstack-brain-enqueue', 'test/helpers/session-runner.ts',
'test/helpers/hermetic-env.ts', 'test/helpers/skill-fixture.ts', 'test/helpers/outside-voice-evidence.ts',
'test/helpers/disabled-plan-review-fixture.ts',
'test/skill-e2e-outside-plan-disabled.test.ts', 'test/fixtures/disabled-plan-attribution-ad-v2.json',
@@ -809,7 +809,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
'test/helpers/docsync-*.ts',
'bin/gstack-session-kind',
'bin/gstack-skill-start',
'hosts/claude/hooks/question-preference-hook.ts',
'hosts/claude/hooks/question-preference-hook.ts', 'lib/bin-context.ts', 'lib/remote-identity.ts',
'hosts/claude/hooks/auq-error-fallback-hook.ts',
'hosts/claude/hooks/spawned-directive.ts',
'scripts/resolvers/preamble/generate-ask-user-format.ts',
@@ -852,7 +852,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
// Deploy skills
'land-and-deploy-workflow': [ 'land-and-deploy/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-deploy.test.ts'],
'land-and-deploy-first-run': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'land-and-deploy/**', 'scripts/gen-skill-docs.ts', 'bin/gstack-slug', 'test/skill-e2e-deploy.test.ts'],
'land-and-deploy-first-run': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'land-and-deploy/**', 'scripts/gen-skill-docs.ts', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'test/skill-e2e-deploy.test.ts'],
'land-and-deploy-review-gate': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'land-and-deploy/**', 'bin/gstack-review-read', 'test/skill-e2e-deploy.test.ts'],
'canary-workflow': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'canary/**', 'scripts/resolvers/aside.ts', 'browse/src/**', 'test/skill-e2e-deploy.test.ts'],
'benchmark-workflow': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'benchmark/**', 'scripts/resolvers/aside.ts', 'browse/src/**', 'test/skill-e2e-deploy.test.ts'],
+11 -1
View File
@@ -44,6 +44,7 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string;
'gstack-distill-free-text',
'gstack-distill-apply',
'gstack-state-root.sh',
'gstack-remote-identity.sh',
]) {
const src = path.join(ROOT, 'bin', script);
if (fs.existsSync(src)) {
@@ -69,7 +70,7 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string;
// These imports are resolved relative to the copied install, not the source checkout.
const libDir = path.join(workDir, 'lib');
fs.mkdirSync(libDir, { recursive: true });
for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts']) {
for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts']) {
fs.copyFileSync(path.join(ROOT, 'lib', file), path.join(libDir, file));
}
@@ -169,6 +170,15 @@ describe('PlanTune cathedral E2E: enforcement', () => {
path.join(fixture.stateRoot, 'projects', fixture.slug, 'question-preferences.json'),
JSON.stringify({ 'ship-changelog-voice-polish': 'never-ask' }),
);
// The skill preamble runs gstack-slug before any question; the hook reads
// the project bucket from the versioned slug-cache entry it writes (#2901).
const slugRun = spawnSync(path.join(fixture.workDir, 'bin', 'gstack-slug'), [], {
cwd: fixture.workDir,
env: { ...fixture.env, GSTACK_STATE_ROOT: fixture.stateRoot },
encoding: 'utf-8',
timeout: 30_000,
});
expect(slugRun.stdout).toContain(`SLUG=${fixture.slug}`);
const hookPath = path.join(
fixture.workDir,
'hosts',
+95 -1
View File
@@ -24,6 +24,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
import { slugCacheFile } from '../lib/bin-context';
const ROOT = path.resolve(import.meta.dir, '..');
const HOOK = path.join(ROOT, 'hosts', 'claude', 'hooks', 'question-preference-hook');
@@ -38,9 +39,16 @@ beforeEach(() => {
cwdSlug = 'fixture-slug';
fs.mkdirSync(path.join(stateRoot, 'projects', cwdSlug), { recursive: true });
// Real directory that the hook can chdir() into. gstack-slug derives the
// slug from the basename of this cwd (no .git => basename fallback path).
// slug from the basename of this cwd (no .git => basename fallback path),
// so the auto-decided event log (written via gstack-question-log) lands in
// the same bucket the cache entry below names.
fixtureCwd = path.join(stateRoot, cwdSlug);
fs.mkdirSync(fixtureCwd, { recursive: true });
// The hook reads the project bucket from gstack-slug's versioned slug-cache
// entry (#2901), which the skill preamble writes before any question.
const cacheFile = slugCacheFile(stateRoot, fixtureCwd);
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
fs.writeFileSync(cacheFile, `v2:${cwdSlug}`);
});
afterEach(() => {
@@ -786,3 +794,89 @@ describe('auto-decided event tagging', () => {
expect(fs.existsSync(markerPath)).toBe(true);
});
});
// ----------------------------------------------------------------------
// Project bucket = gstack-slug's versioned cache (#2901)
// ----------------------------------------------------------------------
describe('project bucket follows gstack-slug (#2901)', () => {
const askPayload = (id: string) => ({
session_id: `s-slug-${id}`,
tool_name: 'AskUserQuestion',
tool_use_id: `tu-slug-${id}`,
tool_input: {
questions: [{ question: '<gstack-qid:ship-pre-landing-review-fix> Fix it?', options: ['A) Fix (recommended)', 'B) Skip'] }],
},
});
function originRepo(dirName: string, origin: string): string {
const dir = path.join(stateRoot, 'repos', dirName);
fs.mkdirSync(dir, { recursive: true });
spawnSync('git', ['init', '-q'], { cwd: dir, timeout: 30_000 });
spawnSync('git', ['remote', 'add', 'origin', origin], { cwd: dir, timeout: 30_000 });
return dir;
}
function writePrefViaBin(cwd: string): string {
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;
env.GSTACK_STATE_ROOT = stateRoot;
delete env.GSTACK_HOME;
delete env.GSTACK_PROJECT_SLUG;
const r = spawnSync(
path.join(ROOT, 'bin', 'gstack-question-preference'),
['--write', JSON.stringify({ question_id: 'ship-pre-landing-review-fix', preference: 'never-ask', source: 'plan-tune' })],
{ cwd, env, encoding: 'utf-8', timeout: 30_000 },
);
expect(r.status).toBe(0);
return r.stdout + r.stderr;
}
test('never-ask written by gstack-question-preference in an origin-remote repo is enforced', () => {
for (const [dirName, origin, bucket] of [
['LovedVoice-checkout', 'git@github.com:kvzn/LovedVoice.git', 'kvzn-LovedVoice'],
['nested-checkout', 'https://gitlab.com/customer-a/product/repo.git', 'product-repo-5eca1fe041984543'],
] as const) {
const repo = originRepo(dirName, origin);
writePrefViaBin(repo);
expect(fs.existsSync(path.join(stateRoot, 'projects', bucket, 'question-preferences.json'))).toBe(true);
const r = runHook(askPayload(dirName), repo);
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny');
expect(r.parsed?.hookSpecificOutput?.permissionDecisionReason).toContain('[plan-tune auto-decide] ship-pre-landing-review-fix → A) Fix');
}
});
test('unresolved identity asks (even with a global never-ask) and records why', () => {
writeGlobalPref('ship-pre-landing-review-fix', 'never-ask');
const unknownCwd = path.join(stateRoot, 'never-resolved');
fs.mkdirSync(unknownCwd, { recursive: true });
expectPassThrough(runHook(askPayload('unresolved'), unknownCwd));
const log = fs.readFileSync(path.join(stateRoot, 'hook-errors.log'), 'utf-8');
expect(log).toContain('never-ask not applied to ship-pre-landing-review-fix, asking instead: project identity unresolved');
expect(log).toContain(unknownCwd);
});
test('an unversioned (pre-upgrade) cache entry is not trusted: asks', () => {
writeProjectPref('ship-pre-landing-review-fix', 'never-ask');
fs.writeFileSync(slugCacheFile(stateRoot, fixtureCwd), cwdSlug);
expectPassThrough(runHook(askPayload('unversioned')));
});
test('GSTACK_PROJECT_SLUG pins the bucket; "." and ".." pins are ignored', () => {
const pinnedCwd = path.join(stateRoot, 'pinned-cwd');
fs.mkdirSync(pinnedCwd, { recursive: true });
fs.mkdirSync(path.join(stateRoot, 'projects', 'pinned'), { recursive: true });
fs.writeFileSync(
path.join(stateRoot, 'projects', 'pinned', 'question-preferences.json'),
JSON.stringify({ 'ship-pre-landing-review-fix': 'never-ask' }),
);
const pinned = runHook(askPayload('pinned'), pinnedCwd, { GSTACK_PROJECT_SLUG: 'pinned' });
expect(pinned.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny');
// Where a ".." pin would point (projects/../): must never be read.
fs.writeFileSync(path.join(stateRoot, 'question-preferences.json'), JSON.stringify({ 'ship-pre-landing-review-fix': 'never-ask' }));
for (const pin of ['..', '.']) {
expectPassThrough(runHook(askPayload(`dots${pin.length}`), pinnedCwd, { GSTACK_PROJECT_SLUG: pin }));
}
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* Canonical remote + slug golden vectors (#3003).
*
* Every implementation of project identity must agree on these vectors:
* lib/remote-identity.ts (bin-context, browse config, global-discover) and
* bin/gstack-remote-identity.sh (gstack-slug, browse/bin/remote-slug). The
* fixture pins literal digests, so a drift in either twin (or in the bash
* sha256 tool selection) fails here rather than splitting a user's state.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { canonicalRemote, legacyRemoteSlug, remoteSlug } from '../lib/remote-identity';
import { normalizeRemoteUrl } from '../bin/gstack-global-discover';
const ROOT = path.resolve(import.meta.dir, '..');
const VECTORS: Array<{ url: string; canonical: string; slug: string; legacy_slug: string }> = JSON.parse(
fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'remote-identity-vectors.json'), 'utf-8'),
);
function bashVectors(): Array<{ canonical: string; slug: string; legacy: string }> {
const script = `set -euo pipefail
. "$1/bin/gstack-remote-identity.sh"
shift
for u in "$@"; do
gstack_remote_slug "$u"
printf '%s\\037%s\\037%s\\n' "$_gri_canon" "$_gri_slug" "$_gri_legacy_slug"
done`;
const r = spawnSync('bash', ['-c', script, 'vectors', ROOT, ...VECTORS.map((v) => v.url)], {
encoding: 'utf-8',
timeout: 30_000,
});
expect(r.status).toBe(0);
return r.stdout
.split('\n')
.filter((l) => l !== '')
.map((l) => {
const [canonical, slug, legacy] = l.split('\x1f');
return { canonical, slug, legacy };
});
}
describe('remote identity golden vectors', () => {
test('fixture covers 2/3/4-segment, ssh/https/port/credential, Azure, Bitbucket, local forms', () => {
expect(VECTORS.length).toBeGreaterThanOrEqual(20);
const nested = VECTORS.filter((v) => v.slug !== v.legacy_slug);
expect(nested.length).toBeGreaterThan(5);
for (const v of nested) expect(v.slug).toMatch(/-[0-9a-f]{16}$/);
});
test('TypeScript twin matches every vector', () => {
for (const v of VECTORS) {
expect({ url: v.url, canonical: canonicalRemote(v.url).canonical, slug: remoteSlug(v.url), legacy: legacyRemoteSlug(v.url) })
.toEqual({ url: v.url, canonical: v.canonical, slug: v.slug, legacy: v.legacy_slug });
}
});
// The bash twin runs on POSIX CI; windows-free-tests covers the TS twin.
test.skipIf(process.platform === 'win32')('bash twin matches every vector byte-for-byte', () => {
const got = bashVectors();
expect(got.length).toBe(VECTORS.length);
VECTORS.forEach((v, i) => {
expect({ url: v.url, ...got[i] }).toEqual({ url: v.url, canonical: v.canonical, slug: v.slug, legacy: v.legacy_slug });
});
});
test('ssh, https, ssh:// with port and credential-bearing https of one nested repo share one slug', () => {
const forms = [
'https://gitlab.com/customer-a/product/repo.git',
'git@gitlab.com:customer-a/product/repo.git',
'ssh://git@gitlab.com:2222/customer-a/product/repo.git',
'https://oauth2:PASSWORD@GitLab.com:8443/customer-a/product/repo.git',
];
const slugs = new Set(forms.map(remoteSlug));
expect(slugs.size).toBe(1);
expect(remoteSlug('https://gitlab.com/customer-b/product/repo.git')).not.toBe([...slugs][0]);
for (const f of forms) expect(canonicalRemote(f).canonical).not.toContain('PASSWORD');
});
test('2-segment remotes keep the legacy slug, so no GitHub bucket moves', () => {
for (const v of VECTORS.filter((x) => canonicalRemote(x.url).hosted && canonicalRemote(x.url).segments.length <= 2)) {
expect(v.slug).toBe(v.legacy_slug);
}
});
test('gstack-global-discover groups by the same canonical remote (ssh:// port no longer leaks into the path)', () => {
for (const v of VECTORS.filter((x) => canonicalRemote(x.url).hosted)) {
expect(normalizeRemoteUrl(v.url)).toBe(`https://${v.canonical}`);
}
expect(normalizeRemoteUrl('ssh://git@host.example:2222/team/repo.git')).toBe('https://host.example/team/repo');
});
});
describe('every resolver files a nested-group repo under the same slug', () => {
const NESTED = 'git@gitlab.com:customer-a/product/repo.git';
const WANT = 'product-repo-5eca1fe041984543';
let tmp: string;
function nestedRepo(): string {
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'resolver-parity-')));
const repo = path.join(tmp, 'checkout');
fs.mkdirSync(repo);
for (const args of [['init', '-q', '-b', 'feat-x'], ['remote', 'add', 'origin', NESTED]]) {
spawnSync('git', args, { cwd: repo, timeout: 30_000 });
}
return repo;
}
test('browse getRemoteSlug', () => {
const repo = nestedRepo();
try {
const r = spawnSync(
'bun',
['-e', `import { getRemoteSlug } from ${JSON.stringify(path.join(ROOT, 'browse', 'src', 'config.ts'))}; console.log(getRemoteSlug());`],
{ cwd: repo, encoding: 'utf-8', timeout: 30_000 },
);
expect(r.stdout.trim()).toBe(WANT);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test.skipIf(process.platform === 'win32')('generated plan-file discovery (_PLAN_SLUG) searches the gstack-slug bucket', () => {
const repo = nestedRepo();
try {
const md = fs.readFileSync(path.join(ROOT, 'review', 'sections', 'plan-completion.md'), 'utf-8');
const block = md.slice(md.indexOf('### Plan File Discovery')).match(/```bash\n([\s\S]*?)```/)![1];
const state = path.join(tmp, '.gstack');
const planDir = path.join(state, 'projects', WANT);
fs.mkdirSync(planDir, { recursive: true });
fs.writeFileSync(path.join(planDir, 'plan.md'), '# plan for feat-x\n');
const { GSTACK_PROJECT_SLUG: _drop, GSTACK_STATE_ROOT: _drop2, ...ambient } = process.env;
const r = spawnSync('bash', ['-c', block.replaceAll('~/.claude/skills/gstack', ROOT)], {
cwd: repo,
env: { ...ambient, HOME: tmp, GSTACK_HOME: state },
encoding: 'utf-8',
timeout: 30_000,
});
expect(r.stdout).toContain(`PLAN_FILE: ${path.join(planDir, 'plan.md')}`);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
+1 -1
View File
@@ -219,7 +219,7 @@ Report the exact output — either "READY: <path>" or "NEEDS_SETUP".`,
// identically on main).
const binDir = path.join(opDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
for (const script of ['gstack-learnings-log', 'gstack-slug', 'gstack-state-root.sh']) {
for (const script of ['gstack-learnings-log', 'gstack-slug', 'gstack-state-root.sh', 'gstack-remote-identity.sh']) {
fs.copyFileSync(path.join(ROOT, 'bin', script), path.join(binDir, script));
fs.chmodSync(path.join(binDir, script), 0o755);
}
+1 -1
View File
@@ -59,7 +59,7 @@ function setupWorkdir(suffix: string): { workDir: string; gstackHome: string; sl
const binDir = path.join(workDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
for (const script of [
'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', 'gstack-state-root.sh',
'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', 'gstack-state-root.sh', 'gstack-remote-identity.sh',
'gstack-learnings-log', 'gstack-learnings-search',
'gstack-update-check', 'gstack-config', 'gstack-repo-mode', 'gstack-paths',
]) {
+1 -1
View File
@@ -40,7 +40,7 @@ describeIfSelected('Learnings E2E', ['learnings-show'], () => {
// Copy bin scripts needed by /learn
const binDir = path.join(workDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
for (const script of ['gstack-learnings-search', 'gstack-learnings-log', 'gstack-slug', 'gstack-state-root.sh']) {
for (const script of ['gstack-learnings-search', 'gstack-learnings-log', 'gstack-slug', 'gstack-state-root.sh', 'gstack-remote-identity.sh']) {
fs.copyFileSync(path.join(ROOT, 'bin', script), path.join(binDir, script));
fs.chmodSync(path.join(binDir, script), 0o755);
}
+1
View File
@@ -51,6 +51,7 @@ describeIfSelected('PlanTune E2E', ['plan-tune-inspect'], () => {
'gstack-slug',
'gstack-config',
'gstack-state-root.sh',
'gstack-remote-identity.sh',
'gstack-question-log',
'gstack-question-preference',
'gstack-developer-profile',
+1 -1
View File
@@ -44,7 +44,7 @@ describeIfSelected('Session Intelligence E2E', [
const binDir = path.join(workDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
for (const script of [
'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', 'gstack-state-root.sh',
'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', 'gstack-state-root.sh', 'gstack-remote-identity.sh',
'gstack-learnings-log', 'gstack-learnings-search',
]) {
const src = path.join(ROOT, 'bin', script);
+1 -1
View File
@@ -96,7 +96,7 @@ function setupSkillifyWorkdir(suffix: string, installSkills: string[] = ['scrape
const binDir = path.join(workDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
for (const script of [
'gstack-timeline-log', 'gstack-slug', 'gstack-config', 'gstack-state-root.sh',
'gstack-timeline-log', 'gstack-slug', 'gstack-config', 'gstack-state-root.sh', 'gstack-remote-identity.sh',
'gstack-update-check', 'gstack-repo-mode',
'gstack-learnings-log', 'gstack-learnings-search',
]) {