Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,8 @@ This handles skills, symlinks, global state (`~/.gstack/`), project-local state,

If you don't have the repo cloned (e.g. you installed via a Claude Code paste and later deleted the clone):

Mirrors `gstack-uninstall --keep-state` (preserves `~/.gstack/` data):

```bash
# 1. Stop browse daemons
pkill -f "gstack.*browse" 2>/dev/null || true
Expand All @@ -352,23 +354,28 @@ done
# 3. Remove gstack
rm -rf ~/.claude/skills/gstack

# 4. Remove global state
rm -rf ~/.gstack

# 5. Remove integrations (skip any you never installed)
# 4. Remove integrations (skip any you never installed)
rm -rf ~/.codex/skills/gstack* 2>/dev/null
rm -rf ~/.factory/skills/gstack* 2>/dev/null
rm -rf ~/.kiro/skills/gstack* 2>/dev/null
rm -rf ~/.openclaw/skills/gstack* 2>/dev/null

# 6. Remove temp files
# 5. Remove temp files
rm -f /tmp/gstack-* 2>/dev/null

# 7. Per-project cleanup (run from each project root)
# 6. Per-project cleanup (run from each project root)
rm -rf .gstack .gstack-worktrees .claude/skills/gstack 2>/dev/null
rm -rf .agents/skills/gstack* .factory/skills/gstack* 2>/dev/null
```

#### Also purge gstack data (optional)

`~/.gstack/` holds config, analytics, sessions, project history, and the installation-id. Run this only if you want a clean slate (equivalent to `gstack-uninstall` without `--keep-state`):

```bash
rm -rf ~/.gstack
```

### Clean up CLAUDE.md

The uninstall script does not edit CLAUDE.md. In each project where gstack was added, remove the `## gstack` and `## Skill routing` sections.
Expand Down
60 changes: 52 additions & 8 deletions bin/gstack-artifacts-init
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#
# Usage:
# gstack-artifacts-init [--remote <url>] [--host github|gitlab|manual]
# [--push-protocol auto|https|ssh]
# [--url-form-supported true|false]
#
# Interactive by default. Pass --remote to skip the host prompt.
Expand Down Expand Up @@ -42,17 +43,25 @@ REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt"

REMOTE_URL=""
HOST_PREF=""
PUSH_PROTOCOL="auto"
REMOTE_SOURCE="provider"
URL_FORM_SUPPORTED="false"
while [ $# -gt 0 ]; do
case "$1" in
--remote) REMOTE_URL="$2"; shift 2 ;;
--remote) REMOTE_URL="$2"; REMOTE_SOURCE="explicit"; shift 2 ;;
--host) HOST_PREF="$2"; shift 2 ;;
--push-protocol) PUSH_PROTOCOL="$2"; shift 2 ;;
--url-form-supported) URL_FORM_SUPPORTED="$2"; shift 2 ;;
--help|-h) sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Unknown flag: $1" >&2; exit 1 ;;
esac
done

case "$PUSH_PROTOCOL" in
auto|https|ssh) ;;
*) echo "Invalid --push-protocol: $PUSH_PROTOCOL (expected auto|https|ssh)" >&2; exit 1 ;;
esac

# ---- preconditions ----
mkdir -p "$GSTACK_HOME"

Expand Down Expand Up @@ -89,6 +98,7 @@ if command -v glab >/dev/null 2>&1 && glab auth status >/dev/null 2>&1; then gla
# ---- choose remote URL ----
if [ -z "$REMOTE_URL" ] && [ -n "$EXISTING_REMOTE" ]; then
REMOTE_URL="$EXISTING_REMOTE"
REMOTE_SOURCE="existing"
echo "Using existing remote: $REMOTE_URL"
fi

Expand Down Expand Up @@ -164,6 +174,7 @@ if [ -z "$REMOTE_URL" ]; then
echo "No URL provided. Aborting." >&2
exit 1
fi
REMOTE_SOURCE="manual"
;;
*) echo "Unknown --host: $HOST_PREF (expected github|gitlab|manual)" >&2; exit 1 ;;
esac
Expand All @@ -179,20 +190,53 @@ if [ -z "$CANONICAL_HTTPS" ]; then
CANONICAL_HTTPS="$REMOTE_URL"
fi

# Use SSH for git push (more reliable for repeated pushes than HTTPS+token).
# Fall back to the canonical input if derivation fails.
PUSH_URL=$("$URL_BIN" --to ssh "$CANONICAL_HTTPS" 2>/dev/null || echo "$CANONICAL_HTTPS")
# Preserve an explicit/existing/manual URL unless the user requests a protocol.
# Provider-created remotes honor the provider CLI's configured git protocol.
RESOLVED_PUSH_PROTOCOL="$PUSH_PROTOCOL"
if [ "$RESOLVED_PUSH_PROTOCOL" = "auto" ]; then
case "$REMOTE_SOURCE" in
explicit|existing|manual)
# Preserve the byte-for-byte URL, including a trailing .git and any
# self-hosted path details. Git credential and insteadOf rules can be
# scoped to that exact URL.
RESOLVED_PUSH_PROTOCOL="preserve"
;;
provider)
CONFIGURED_PROTOCOL=""
case "$HOST_PREF" in
github) CONFIGURED_PROTOCOL=$(gh config get git_protocol 2>/dev/null || true) ;;
gitlab) CONFIGURED_PROTOCOL=$(glab config get git_protocol 2>/dev/null || true) ;;
esac
case "$CONFIGURED_PROTOCOL" in
ssh|https) RESOLVED_PUSH_PROTOCOL="$CONFIGURED_PROTOCOL" ;;
*) RESOLVED_PUSH_PROTOCOL="https" ;;
esac
;;
esac
fi

if [ "$RESOLVED_PUSH_PROTOCOL" = "preserve" ]; then
PUSH_URL="$REMOTE_URL"
case "$REMOTE_URL" in
git@*|ssh://*) PROTOCOL_LABEL="ssh" ;;
http://*|https://*) PROTOCOL_LABEL="https" ;;
*) PROTOCOL_LABEL="the remote's configured protocol" ;;
esac
else
PUSH_URL=$("$URL_BIN" --to "$RESOLVED_PUSH_PROTOCOL" "$CANONICAL_HTTPS" 2>/dev/null || echo "$CANONICAL_HTTPS")
PROTOCOL_LABEL="$RESOLVED_PUSH_PROTOCOL"
fi

# ---- verify push URL is reachable ----
echo "Verifying remote connectivity: $PUSH_URL"
if ! git ls-remote "$PUSH_URL" >/dev/null 2>&1; then
cat >&2 <<EOF
Remote not reachable via SSH: $PUSH_URL
Remote not reachable via $PROTOCOL_LABEL: $PUSH_URL
This could mean:
- Wrong URL
- SSH key not added to your git host (GitHub: gh ssh-key list; GitLab: glab ssh-key list)
- Credentials for $PROTOCOL_LABEL are not configured for your git host
- Network issue
Fix and re-run gstack-artifacts-init.
Fix and re-run gstack-artifacts-init, or choose --push-protocol https|ssh.
EOF
exit 1
fi
Expand Down Expand Up @@ -373,7 +417,7 @@ cat <<EOF
gstack-artifacts-init complete.
Repo: $GSTACK_HOME (git)
Remote: $CANONICAL_HTTPS (canonical form, in ~/.gstack-artifacts-remote.txt)
Push: $PUSH_URL (derived SSH form for git push)
Push: $PUSH_URL ($RESOLVED_PUSH_PROTOCOL form for git push)

EOF

Expand Down
33 changes: 33 additions & 0 deletions bin/gstack-ios-qa-daemon
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,39 @@

set -euo pipefail

print_usage() {
cat <<'EOF'
Usage:
gstack-ios-qa-daemon [--tailnet]
gstack-ios-qa-daemon --help

Mac-side broker for /ios-qa and /ios-design-review. By default it binds a
loopback listener for a USB-connected iPhone running the in-app StateServer.
Pass --tailnet to also open the Tailscale listener after the local tailscaled
identity probe succeeds.

Options:
--tailnet Enable the authenticated tailnet listener in addition to loopback.
-h, --help Print this help and exit without starting the daemon.

Environment:
GSTACK_IOS_DAEMON_PORT Loopback listener port. Default: 9099.
GSTACK_IOS_TARGET_UDID Target iOS device UDID. Optional.
GSTACK_IOS_TARGET_BUNDLE_ID Bundle ID hosting StateServer. Default: com.gstack.iosqa.fixture.

Readiness:
The daemon prints "READY: port=<n> pid=<pid>" once the listener is bound.
Probe local health with: curl -sf http://127.0.0.1:${GSTACK_IOS_DAEMON_PORT:-9099}/healthz
EOF
}

case "${1:-}" in
-h|--help)
print_usage
exit 0
;;
esac

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GSTACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ENTRY="$GSTACK_DIR/ios-qa/daemon/src/index.ts"
Expand Down
62 changes: 62 additions & 0 deletions bin/gstack-memory-ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,19 @@ function* walkAllSources(ctx: WalkContext): Generator<{ path: string; type: Memo
yield* walkGstackArtifacts(ctx);
}

function transcriptIngestMode(): "enabled" | "off" {
try {
const raw = readFileSync(join(GSTACK_HOME, "config.yaml"), "utf-8");
const match = raw.match(/^transcript_ingest_mode:\s*([^#\s]+)\s*(?:#.*)?$/m);
const value = match?.[1]?.toLowerCase();
return value === "off" ? "off" : "enabled";
} catch {
// Preserve compatibility for pre-setting installs. Repository trust below
// still fails closed for attributed transcripts.
return "enabled";
}
}

// ── Renderers ──────────────────────────────────────────────────────────────

interface ParsedSession {
Expand Down Expand Up @@ -683,6 +696,29 @@ function resolveGitRemote(cwd: string): string {
}
}

let cachedCurrentRepoRemote: string | undefined;
function currentRepoRemote(): string {
if (cachedCurrentRepoRemote !== undefined) return cachedCurrentRepoRemote;
cachedCurrentRepoRemote = resolveGitRemote(process.cwd());
return cachedCurrentRepoRemote;
}

let cachedRepoPolicy: Record<string, unknown> | undefined;
function repoTrustTier(remote: string): string {
if (!remote) return "unset";
try {
cachedRepoPolicy ??= JSON.parse(
readFileSync(join(GSTACK_HOME, "gbrain-repo-policy.json"), "utf-8"),
) as Record<string, unknown>;
const tier = cachedRepoPolicy[canonicalizeRemote(remote)];
return typeof tier === "string" ? tier : "unset";
} catch {
// Missing, unreadable, or corrupt policy is not authorization.
cachedRepoPolicy = {};
return "unset";
}
}

function repoSlug(remote: string): string {
if (!remote) return "_unattributed";
// github.com/foo/bar → foo-bar
Expand Down Expand Up @@ -1171,6 +1207,32 @@ function preparePages(
skippedUnattributed++;
continue;
}
if (args.mode !== "probe") {
if (transcriptIngestMode() === "off") {
skippedUnattributed++;
continue;
}
const pageRemote = canonicalizeRemote(page.git_remote || "");
const explicitlyUnattributed =
(!page.git_remote || page.git_remote === "_unattributed") && args.includeUnattributed;
if (!explicitlyUnattributed) {
const currentRemote = currentRepoRemote();
if (
!currentRemote ||
pageRemote !== currentRemote ||
repoTrustTier(pageRemote) !== "read-write"
) {
skippedUnattributed++;
if (!args.quiet) {
console.error(
`[trust-policy] skipped transcript from ${pageRemote || "_unattributed"}; ` +
"only the current repo with an explicit read-write policy may ingest transcripts.",
);
}
continue;
}
}
}
if (page.partial) partialPages++;
} else {
page = buildArtifactPage(path, type);
Expand Down
16 changes: 15 additions & 1 deletion bin/gstack-model-benchmark
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ function parseProviders(s: string | undefined): Array<'claude' | 'gpt' | 'gemini
return seen.size ? Array.from(seen) : ['claude'];
}

function parsePositiveIntegerFlag(name: string, def: string): number {
const raw = arg(name, def);
if (!raw || !/^\+?[1-9]\d*$/.test(raw)) {
console.error(`${name} requires a positive integer`);
process.exit(1);
}
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed)) {
console.error(`${name} requires a positive integer`);
process.exit(1);
}
return parsed;
}

function resolvePrompt(positional: string | undefined): string {
const inline = arg('--prompt');
if (inline) return inline;
Expand All @@ -107,7 +121,7 @@ async function main(): Promise<void> {
const prompt = resolvePrompt(positional);
const providers = parseProviders(arg('--models'));
const workdir = arg('--workdir', process.cwd())!;
const timeoutMs = parseInt(arg('--timeout-ms', '300000')!, 10);
const timeoutMs = parsePositiveIntegerFlag('--timeout-ms', '300000');
const output = (arg('--output', 'table') as OutputFormat);
const skipUnavailable = flag('--skip-unavailable');
const doJudge = flag('--judge');
Expand Down
18 changes: 12 additions & 6 deletions bin/gstack-paths
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@
# PLAN_ROOT: GSTACK_PLAN_DIR -> CLAUDE_PLANS_DIR -> $HOME/.claude/plans -> .claude/plans
# TMP_ROOT: TMPDIR -> TMP -> .gstack/tmp (and mkdir -p, best-effort)
#
# Security: output values are not sanitized — callers may receive paths with
# shell-special characters if env vars contain them. Skills should always quote
# expansions ("$GSTACK_STATE_ROOT", not $GSTACK_STATE_ROOT).
# Output values are shell-quoted so eval "$(gstack-paths)" is safe for paths
# containing spaces, $, ;, backticks, and other metacharacters. Single quotes
# are used for portability (POSIX sh / bash / zsh / dash).
set -u

# Wrap a path in single quotes, escaping any embedded single quotes.
# Produces output safe for: eval "$(gstack-paths)"
_shell_quote() {
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
}

# State root: where gstack writes projects/, sessions/, analytics/.
if [ -n "${GSTACK_HOME:-}" ]; then
_state_root="$GSTACK_HOME"
Expand Down Expand Up @@ -60,6 +66,6 @@ fi
# will discover that on their own write attempt. Don't fail the eval here.
mkdir -p "$_tmp_root" 2>/dev/null || true

echo "GSTACK_STATE_ROOT=$_state_root"
echo "PLAN_ROOT=$_plan_root"
echo "TMP_ROOT=$_tmp_root"
printf 'GSTACK_STATE_ROOT=%s\n' "$(_shell_quote "$_state_root")"
printf 'PLAN_ROOT=%s\n' "$(_shell_quote "$_plan_root")"
printf 'TMP_ROOT=%s\n' "$(_shell_quote "$_tmp_root")"
Loading
Loading