mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
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:
1 parent
bc8bec4df2
commit
63a6cbb8ee
34 files changed
+1745
-130
No files matched your search
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
]) {
|
||||
|
||||
Reference in new issue
Block a user