Files
gstack/bin/gstack-global-discover.ts
T
f30b7b788a v1.91.13.0 fix: severe bug wave (autoplan guard, land-and-deploy CI gate, project identity, gbrain, review gates) (#3013)
* fix(land-and-deploy): never merge over red, pending or missing CI (#2995)

/land-and-deploy gated only on required checks, so a repo with none
(every GitHub Free private repo, any repo without branch protection)
skipped the CI wait and could merge over red CI. The verdict now comes
from bin/gstack-ci-gate, a tested helper bound to the head SHA: all
checks by default, cancel/unknown buckets fail, NO_CHECKS needs explicit
per-head approval, and both merge commands (including --auto) run only
after a PASS for the same SHA they pin with --match-head-commit.

Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com>

* 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.

* fix(gbrain): brain context loads, managed sync works, memory sync stops duplicating (#2883, #2985, #2922)

- brain-context-load called 'gbrain list_pages' (an MCP tool name), so
  every skill's brain context was 0 bytes. It now uses 'gbrain list'
  with gbrain's real filter flags, and prints one line when queries fail.
- The code-stage sync always passes --no-pull (managed gbrain >= 0.51
  refuses without it); a gbrain that rejects the flag fails with an
  upgrade message and is never retried without it. stderr is streamed
  with a bounded tail and quoted in failure summaries.
- /sync-gbrain gains --sources (and GSTACK_MEMORY_INGEST_SOURCES); by
  default it skips types already synced through a federated curated
  source, so curated pages stop being re-duplicated.

Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com>

* fix(codex,cso): clean Codex reviews stop failing; semgrep and trivy SARIF results import (#2769, #3011)

- Codex review gate is PASS / FAIL / UNVERIFIED. Untagged output (what
  a clean 'codex review --base' prints) is UNVERIFIED, not FAIL, and
  never a false PASS; P2/P3-only output passes.
- import-sarif resolves semgrep's undeclared %SRCROOT% and trivy's
  ROOTPATH (including git-URL targets) through explicit, captured
  mappings; declared bases must sit inside the source root or the
  run's checkout, URIs are percent-decoded, and rejected results are
  counted with rule, base and path instead of silently dropped.

* fix: version strings are not IPs, failed update checks are not 'up to date', private security reporting (#2856, #2786, #2984)

- The pre-push redact hook exempts a root VERSION file that is only a
  version string and the version token in CHANGELOG release headings;
  the engine allowlist is untouched.
- gstack-update-check caches an unreadable remote as CHECK_FAILED with
  a short TTL instead of UP_TO_DATE; /gstack-upgrade never reports
  'latest' after a failed check.
- SECURITY.md points reporters at GitHub private vulnerability
  reporting.
- pr-title-sync (pull_request_target, write token) pins actions/checkout
  to a reviewed SHA.

* fix(autoplan): publication guard accepts real Claude Code journals (#3007, #3009, #2968, #2977, #2986, #3006)

The guard required a session journal to start with a user message, but
Claude Code starts it with SessionStart hook attachments (and a forked
compacted session starts with a compact_boundary), so every phase was
denied for anyone with a SessionStart hook. Windows path spellings
(C:/, /c/, drive case) were rejected too.

- Reader accepts the captured startup, compact, clear and fork shapes
  (claude 2.1.284 fixture), computes root uniqueness over every
  candidate, and returns typed reason codes instead of one string.
- Hard denials (competing root, foreign cwd, sidechain, agent, cycle,
  malformed, identity) say what happened and how to run the reviews by
  hand; unflushed journals still retry.
- A well-formed, stable journal whose root shape is unrecognized now
  warns ('phase publication was NOT verified') and continues, without
  overriding Claude Code's own permission check; no bypass switch.
- Windows paths fold before validation, so '..' is still rejected.
- Periodic drift canary runs the reader on fresh journals from the
  pinned claude.

Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com>
Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com>
Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com>
Co-authored-by: elmesery-dev <elmesery.dev@gmail.com>

* v1.91.13.0: severe bug fix wave release notes, stale gbrain verbs in docs

* test: seed the versioned slug cache in the memory-injection deny case (#2901)

The preference hook now resolves the project bucket from gstack-slug's
v2 slug-cache entry instead of basename(cwd); this fixture still wrote
preferences under the basename bucket without the entry the skill
preamble always writes first.

* fix: full-suite follow-ups for the fix wave

- gstack-slug's --help heredoc is over 512B, so it gets the BASH_COMPAT=50
  pipe-deadlock guard like the other bins.
- Refresh the codex/factory ship goldens for the plan-gates slug change.
- The periodic lane gains the autoplan journal drift canary (107 paid
  files); raise the eval-slices cap to 25 so every slice still starts
  at once until its recorded duration lets the planner pack it.

* fix(plan-eng-review): clearer Step 0 sequencing, write routes and section outputs

The workflow judge scored plan-eng-review's instructions at
actionability 3.67 (bar 4) on text unchanged from main. Resolve the
ambiguities it named together, without changing any gate or output:
Step 0 names the Read's order and keeps one STOP; the pending-call,
read-only, forbidden-write and failed-save rules become one Write
routes block; header/label limits and grid checks are concrete;
Performance gets a checklist; each section has a stated output shape;
Lake Score is defined. Same total length (parity cap).

---------

Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com>
Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com>
Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com>
Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com>
Co-authored-by: elmesery-dev <elmesery.dev@gmail.com>
2026-10-02 11:11:00 -07:00

589 lines
19 KiB
TypeScript

#!/usr/bin/env bun
/**
* gstack-global-discover — Discover AI coding sessions across Claude Code, Codex CLI, and Gemini CLI.
* Resolves each session's working directory to a git repo, deduplicates by normalized remote URL,
* and outputs structured JSON to stdout.
*
* Usage:
* gstack-global-discover --since 7d [--format json|summary]
* gstack-global-discover --help
*/
import { existsSync, readdirSync, statSync, readFileSync, openSync, readSync, closeSync } from "fs";
import { join, basename } from "path";
import { execSync } from "child_process";
import { homedir } from "os";
import { canonicalRemote } from "../lib/remote-identity";
// ── Types ──────────────────────────────────────────────────────────────────
interface Session {
tool: "claude_code" | "codex" | "gemini";
cwd: string;
}
interface Repo {
name: string;
remote: string;
paths: string[];
sessions: { claude_code: number; codex: number; gemini: number };
}
interface DiscoveryResult {
window: string;
start_date: string;
repos: Repo[];
tools: {
claude_code: { total_sessions: number; repos: number };
codex: { total_sessions: number; repos: number };
gemini: { total_sessions: number; repos: number };
};
total_sessions: number;
total_repos: number;
}
// ── CLI parsing ────────────────────────────────────────────────────────────
function printUsage(): void {
console.error(`Usage: gstack-global-discover --since <window> [--format json|summary]
--since <window> Time window: e.g. 7d, 14d, 30d, 24h
--format <fmt> Output format: json (default) or summary
--help Show this help
Examples:
gstack-global-discover --since 7d
gstack-global-discover --since 14d --format summary`);
}
function parseArgs(): { since: string; format: "json" | "summary" } {
const args = process.argv.slice(2);
let since = "";
let format: "json" | "summary" = "json";
for (let i = 0; i < args.length; i++) {
if (args[i] === "--help" || args[i] === "-h") {
printUsage();
process.exit(0);
} else if (args[i] === "--since" && args[i + 1]) {
since = args[++i];
} else if (args[i] === "--format" && args[i + 1]) {
const f = args[++i];
if (f !== "json" && f !== "summary") {
console.error(`Invalid format: ${f}. Use 'json' or 'summary'.`);
printUsage();
process.exit(1);
}
format = f;
} else {
console.error(`Unknown argument: ${args[i]}`);
printUsage();
process.exit(1);
}
}
if (!since) {
console.error("Error: --since is required.");
printUsage();
process.exit(1);
}
if (!/^\d+(d|h|w)$/.test(since)) {
console.error(`Invalid window format: ${since}. Use e.g. 7d, 24h, 2w.`);
process.exit(1);
}
return { since, format };
}
function windowToDate(window: string): Date {
const match = window.match(/^(\d+)(d|h|w)$/);
if (!match) throw new Error(`Invalid window: ${window}`);
const [, numStr, unit] = match;
const num = parseInt(numStr, 10);
const now = new Date();
if (unit === "h") {
return new Date(now.getTime() - num * 60 * 60 * 1000);
} else if (unit === "w") {
// weeks — midnight-aligned like days
const d = new Date(now);
d.setDate(d.getDate() - num * 7);
d.setHours(0, 0, 0, 0);
return d;
} else {
// days — midnight-aligned
const d = new Date(now);
d.setDate(d.getDate() - num);
d.setHours(0, 0, 0, 0);
return d;
}
}
// ── URL normalization ──────────────────────────────────────────────────────
export function normalizeRemoteUrl(url: string): string {
// 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 ────────────────────────────────────────────────────────────
function isGitRepo(dir: string): boolean {
return existsSync(join(dir, ".git"));
}
function getGitRemote(cwd: string): string | null {
if (!existsSync(cwd) || !isGitRepo(cwd)) return null;
try {
const remote = execSync("git remote get-url origin", {
cwd,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim();
return remote || null;
} catch (err: any) {
// Expected: no remote configured, repo not found, git not installed
if (err?.status !== undefined) return null; // non-zero exit from git
if (err?.code === 'ENOENT') return null; // git binary not found
throw err;
}
}
// ── Scanners ───────────────────────────────────────────────────────────────
function scanClaudeCode(since: Date): Session[] {
const projectsDir = join(homedir(), ".claude", "projects");
if (!existsSync(projectsDir)) return [];
const sessions: Session[] = [];
let dirs: string[];
try {
dirs = readdirSync(projectsDir);
} catch (err: any) {
if (err?.code === 'ENOENT' || err?.code === 'EACCES') return [];
throw err;
}
for (const dirName of dirs) {
const dirPath = join(projectsDir, dirName);
try {
const stat = statSync(dirPath);
if (!stat.isDirectory()) continue;
} catch {
continue;
}
// Find JSONL files
let jsonlFiles: string[];
try {
jsonlFiles = readdirSync(dirPath).filter((f) => f.endsWith(".jsonl"));
} catch {
continue;
}
if (jsonlFiles.length === 0) continue;
// Coarse mtime pre-filter: check if any JSONL file is recent
const hasRecentFile = jsonlFiles.some((f) => {
try {
return statSync(join(dirPath, f)).mtime >= since;
} catch (err: any) {
if (err?.code === 'ENOENT' || err?.code === 'EACCES') return false;
throw err;
}
});
if (!hasRecentFile) continue;
// Resolve cwd
let cwd = resolveClaudeCodeCwd(dirPath, dirName, jsonlFiles);
if (!cwd) continue;
// Count only JSONL files modified within the window as sessions
const recentFiles = jsonlFiles.filter((f) => {
try {
return statSync(join(dirPath, f)).mtime >= since;
} catch (err: any) {
if (err?.code === 'ENOENT' || err?.code === 'EACCES') return false;
throw err;
}
});
for (let i = 0; i < recentFiles.length; i++) {
sessions.push({ tool: "claude_code", cwd });
}
}
return sessions;
}
function resolveClaudeCodeCwd(
dirPath: string,
dirName: string,
jsonlFiles: string[]
): string | null {
// Fast-path: decode directory name
// e.g., -Users-garrytan-git-repo → /Users/garrytan/git/repo
const decoded = dirName.replace(/^-/, "/").replace(/-/g, "/");
if (existsSync(decoded)) return decoded;
// Fallback: read cwd from first JSONL file
// Sort by mtime descending, pick most recent
const sorted = jsonlFiles
.map((f) => {
try {
return { name: f, mtime: statSync(join(dirPath, f)).mtime.getTime() };
} catch (err: any) {
if (err?.code === 'ENOENT' || err?.code === 'EACCES') return null;
throw err;
}
})
.filter(Boolean)
.sort((a, b) => b!.mtime - a!.mtime) as { name: string; mtime: number }[];
for (const file of sorted.slice(0, 3)) {
const cwd = extractCwdFromJsonl(join(dirPath, file.name));
if (cwd && existsSync(cwd)) return cwd;
}
return null;
}
export function extractCwdFromJsonl(filePath: string): string | null {
// Read a capped prefix so huge JSONL files don't blow up memory. 64KB
// comfortably fits the largest observed session headers; the old 8KB cap
// would sometimes fall inside a single long line and silently drop the
// project (JSON.parse failure on the truncated tail).
const MAX_BYTES = 64 * 1024;
const MAX_LINES = 30;
try {
const fd = openSync(filePath, "r");
const buf = Buffer.alloc(MAX_BYTES);
const bytesRead = readSync(fd, buf, 0, MAX_BYTES, 0);
closeSync(fd);
const text = buf.toString("utf-8", 0, bytesRead);
// Drop the final segment — it may be an incomplete line at the cap boundary.
const parts = text.split("\n");
const completeLines = parts.length > 1 ? parts.slice(0, -1) : parts;
for (const line of completeLines.slice(0, MAX_LINES)) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
if (obj.cwd) return obj.cwd;
} catch {
continue;
}
}
} catch {
// File read error
}
return null;
}
function scanCodex(since: Date): Session[] {
const sessionsDir = process.env.CODEX_SESSIONS_DIR || join(homedir(), ".codex", "sessions");
if (!existsSync(sessionsDir)) return [];
const sessions: Session[] = [];
// Walk YYYY/MM/DD directory structure
try {
const years = readdirSync(sessionsDir);
for (const year of years) {
const yearPath = join(sessionsDir, year);
if (!statSync(yearPath).isDirectory()) continue;
const months = readdirSync(yearPath);
for (const month of months) {
const monthPath = join(yearPath, month);
if (!statSync(monthPath).isDirectory()) continue;
const days = readdirSync(monthPath);
for (const day of days) {
const dayPath = join(monthPath, day);
if (!statSync(dayPath).isDirectory()) continue;
const files = readdirSync(dayPath).filter((f) =>
f.startsWith("rollout-") && f.endsWith(".jsonl")
);
for (const file of files) {
const filePath = join(dayPath, file);
try {
const stat = statSync(filePath);
if (stat.mtime < since) continue;
} catch {
continue;
}
// Codex session_meta lines embed the full system prompt in
// base_instructions (~15KB as of CLI v0.117+). A 4KB buffer
// truncates the line and JSON.parse fails. 128KB covers current
// sizes with room for growth.
try {
const fd = openSync(filePath, "r");
const buf = Buffer.alloc(131072);
const bytesRead = readSync(fd, buf, 0, 131072, 0);
closeSync(fd);
const firstLine = buf.toString("utf-8", 0, bytesRead).split("\n")[0];
if (!firstLine) continue;
const meta = JSON.parse(firstLine);
if (meta.type === "session_meta" && meta.payload?.cwd) {
sessions.push({ tool: "codex", cwd: meta.payload.cwd });
}
} catch {
console.error(`Warning: could not parse Codex session ${filePath}`);
}
}
}
}
}
} catch {
// Directory read error
}
return sessions;
}
function scanGemini(since: Date): Session[] {
const tmpDir = join(homedir(), ".gemini", "tmp");
if (!existsSync(tmpDir)) return [];
// Load projects.json for path mapping
const projectsPath = join(homedir(), ".gemini", "projects.json");
let projectsMap: Record<string, string> = {}; // name → path
if (existsSync(projectsPath)) {
try {
const data = JSON.parse(readFileSync(projectsPath, { encoding: "utf-8" }));
// Format: { projects: { "/path": "name" } } — we want name → path
const projects = data.projects || {};
for (const [path, name] of Object.entries(projects)) {
projectsMap[name as string] = path;
}
} catch {
console.error("Warning: could not parse ~/.gemini/projects.json");
}
}
const sessions: Session[] = [];
const seenTimestamps = new Map<string, Set<string>>(); // projectName → Set<startTime>
let projectDirs: string[];
try {
projectDirs = readdirSync(tmpDir);
} catch (err: any) {
if (err?.code === 'ENOENT' || err?.code === 'EACCES') return [];
throw err;
}
for (const projectName of projectDirs) {
const chatsDir = join(tmpDir, projectName, "chats");
if (!existsSync(chatsDir)) continue;
// Resolve cwd from projects.json
let cwd = projectsMap[projectName] || null;
// Fallback: check .project_root
if (!cwd) {
const projectRootFile = join(tmpDir, projectName, ".project_root");
if (existsSync(projectRootFile)) {
try {
cwd = readFileSync(projectRootFile, { encoding: "utf-8" }).trim();
} catch {}
}
}
if (!cwd || !existsSync(cwd)) continue;
const seen = seenTimestamps.get(projectName) || new Set<string>();
seenTimestamps.set(projectName, seen);
let files: string[];
try {
files = readdirSync(chatsDir).filter((f) =>
f.startsWith("session-") && f.endsWith(".json")
);
} catch {
continue;
}
for (const file of files) {
const filePath = join(chatsDir, file);
try {
const stat = statSync(filePath);
if (stat.mtime < since) continue;
} catch {
continue;
}
try {
const data = JSON.parse(readFileSync(filePath, { encoding: "utf-8" }));
const startTime = data.startTime || "";
// Deduplicate by startTime within project
if (startTime && seen.has(startTime)) continue;
if (startTime) seen.add(startTime);
sessions.push({ tool: "gemini", cwd });
} catch {
console.error(`Warning: could not parse Gemini session ${filePath}`);
}
}
}
return sessions;
}
// ── Deduplication ──────────────────────────────────────────────────────────
async function resolveAndDeduplicate(sessions: Session[]): Promise<Repo[]> {
// Group sessions by cwd
const byCwd = new Map<string, Session[]>();
for (const s of sessions) {
const existing = byCwd.get(s.cwd) || [];
existing.push(s);
byCwd.set(s.cwd, existing);
}
// Resolve git remotes for each cwd
const cwds = Array.from(byCwd.keys());
const remoteMap = new Map<string, string>(); // cwd → normalized remote
for (const cwd of cwds) {
const raw = getGitRemote(cwd);
if (raw) {
remoteMap.set(cwd, normalizeRemoteUrl(raw));
} else if (existsSync(cwd) && isGitRepo(cwd)) {
remoteMap.set(cwd, `local:${cwd}`);
}
}
// Group by normalized remote
const byRemote = new Map<string, { paths: string[]; sessions: Session[] }>();
for (const [cwd, cwdSessions] of byCwd) {
const remote = remoteMap.get(cwd);
if (!remote) continue;
const existing = byRemote.get(remote) || { paths: [], sessions: [] };
if (!existing.paths.includes(cwd)) existing.paths.push(cwd);
existing.sessions.push(...cwdSessions);
byRemote.set(remote, existing);
}
// Build Repo objects
const repos: Repo[] = [];
for (const [remote, data] of byRemote) {
// Find first valid path
const validPath = data.paths.find((p) => existsSync(p) && isGitRepo(p));
if (!validPath) continue;
// Derive name from remote URL
let name: string;
if (remote.startsWith("local:")) {
name = basename(remote.replace("local:", ""));
} else {
try {
const url = new URL(remote);
name = basename(url.pathname);
} catch {
name = basename(remote);
}
}
const sessionCounts = { claude_code: 0, codex: 0, gemini: 0 };
for (const s of data.sessions) {
sessionCounts[s.tool]++;
}
repos.push({
name,
remote,
paths: data.paths,
sessions: sessionCounts,
});
}
// Sort by total sessions descending
repos.sort(
(a, b) =>
b.sessions.claude_code + b.sessions.codex + b.sessions.gemini -
(a.sessions.claude_code + a.sessions.codex + a.sessions.gemini)
);
return repos;
}
// ── Main ───────────────────────────────────────────────────────────────────
async function main() {
const { since, format } = parseArgs();
const sinceDate = windowToDate(since);
const startDate = sinceDate.toISOString().split("T")[0];
// Run all scanners
const ccSessions = scanClaudeCode(sinceDate);
const codexSessions = scanCodex(sinceDate);
const geminiSessions = scanGemini(sinceDate);
const allSessions = [...ccSessions, ...codexSessions, ...geminiSessions];
// Summary to stderr
console.error(
`Discovered: ${ccSessions.length} CC sessions, ${codexSessions.length} Codex sessions, ${geminiSessions.length} Gemini sessions`
);
// Deduplicate
const repos = await resolveAndDeduplicate(allSessions);
console.error(`→ ${repos.length} unique repos`);
// Count per-tool repo counts
const ccRepos = new Set(repos.filter((r) => r.sessions.claude_code > 0).map((r) => r.remote)).size;
const codexRepos = new Set(repos.filter((r) => r.sessions.codex > 0).map((r) => r.remote)).size;
const geminiRepos = new Set(repos.filter((r) => r.sessions.gemini > 0).map((r) => r.remote)).size;
const result: DiscoveryResult = {
window: since,
start_date: startDate,
repos,
tools: {
claude_code: { total_sessions: ccSessions.length, repos: ccRepos },
codex: { total_sessions: codexSessions.length, repos: codexRepos },
gemini: { total_sessions: geminiSessions.length, repos: geminiRepos },
},
total_sessions: allSessions.length,
total_repos: repos.length,
};
if (format === "json") {
console.log(JSON.stringify(result, null, 2));
} else {
// Summary format
console.log(`Window: ${since} (since ${startDate})`);
console.log(`Sessions: ${allSessions.length} total (CC: ${ccSessions.length}, Codex: ${codexSessions.length}, Gemini: ${geminiSessions.length})`);
console.log(`Repos: ${repos.length} unique`);
console.log("");
for (const repo of repos) {
const total = repo.sessions.claude_code + repo.sessions.codex + repo.sessions.gemini;
const tools = [];
if (repo.sessions.claude_code > 0) tools.push(`CC:${repo.sessions.claude_code}`);
if (repo.sessions.codex > 0) tools.push(`Codex:${repo.sessions.codex}`);
if (repo.sessions.gemini > 0) tools.push(`Gemini:${repo.sessions.gemini}`);
console.log(` ${repo.name} (${total} sessions) — ${tools.join(", ")}`);
console.log(` Remote: ${repo.remote}`);
console.log(` Paths: ${repo.paths.join(", ")}`);
}
}
}
// Only run main when executed directly (not when imported for testing)
if (import.meta.main) {
main().catch((err) => {
console.error(`Fatal error: ${err.message}`);
process.exit(1);
});
}