Merge remote-tracking branch 'origin/main' into garrytan/upgrade-gbrain-gstack

# Conflicts:
#	bin/gstack-gbrain-sync.ts
#	lib/gbrain-sources.ts
This commit is contained in:
Garry Tan
2026-05-31 09:43:12 -07:00
156 changed files with 15818 additions and 5097 deletions
+17 -2
View File
@@ -56,8 +56,23 @@ if [ ! -e "$AGENTS_LINK" ]; then
ln -s "$REPO_ROOT" "$AGENTS_LINK"
fi
# 6. Run setup via the symlink so it detects .claude/skills/ as its parent
"$GSTACK_LINK/setup"
# 6. Run setup via the symlink so it detects .claude/skills/ as its parent.
#
# Workspace/dev setup MUST be non-interactive: Conductor runs this under a
# forwarded pty, so any `read` in setup (skill-prefix prompt, plan-tune hook
# consent) would hang the workspace forever. Detaching stdin makes every setup
# prompt take its smart non-interactive default (flat skill names, etc.).
#
# `--plan-tune-hooks=prompt` is load-bearing, not redundant: stdin alone only
# suppresses the *prompt* branch. A saved `plan_tune_hooks: yes` or an exported
# GSTACK_PLAN_TUNE_HOOKS=yes would still resolve to "install" and rewrite the
# user's global ~/.claude/settings.json to point at THIS ephemeral worktree —
# which breaks once the workspace is deleted. The flag has highest precedence,
# so it pins resolution to "prompt", and closed stdin then makes prompt-mode a
# no-op skip (no install, no decline marker). A dev workspace must never mutate
# global settings.json. To install the hooks, run `./setup --plan-tune-hooks`
# directly (outside dev-setup). Saved prefix/other config preferences still apply.
"$GSTACK_LINK/setup" --plan-tune-hooks=prompt </dev/null
echo ""
echo "Dev mode active. Skills resolve from this working tree."
+949
View File
@@ -0,0 +1,949 @@
#!/usr/bin/env bun
/**
* gstack-brain-cache — three-tier cache for brain-aware planning skills.
*
* Subcommands:
* get <entity-name> [--project <slug>] — return digest content; refresh if stale
* refresh [--full] [--entity X] [--project <slug>] — force refresh one or all
* invalidate <entity-name> [--project <slug>] — mark stale; next get triggers cold
* digest <entity-slug> — compress a brain page slug to digest
* meta [--project <slug>] — print _meta.json
*
* (Later commits add: bootstrap [T2b], list [T18], purge [T18], retention sweep [T18].)
*
* Cache layout:
* ~/.gstack/brain-cache/ ← cross-project (user-profile only)
* ~/.gstack/projects/<slug>/brain-cache/ ← per-project (everything else)
*
* Atomic writes via .tmp + rename. Stale-but-usable fallback when brain
* unreachable. Concurrent-refresh dedup is a follow-up commit (T15).
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, statSync, unlinkSync, readdirSync, openSync, closeSync } from 'fs';
import { join, dirname } from 'path';
import { homedir, hostname } from 'os';
import { spawnSync } from 'child_process';
import { execGbrainJson, spawnGbrain } from '../lib/gbrain-exec';
import {
BRAIN_CACHE_ENTITIES,
CACHE_REFRESH_LOCK_TIMEOUT_MS,
GSTACK_SCHEMA_PACK_NAME,
GSTACK_SCHEMA_PACK_VERSION,
SALIENCE_DEFAULT_ALLOWLIST,
type BrainCacheEntity,
} from '../scripts/brain-cache-spec';
// ──────────────────────────────────────────────────────────────────────────
// Paths + meta
// ──────────────────────────────────────────────────────────────────────────
const GSTACK_HOME = process.env.GSTACK_HOME || join(homedir(), '.gstack');
interface CacheMeta {
/** Version of the schema pack the cache was built against. Mismatch → full rebuild. */
schema_version: string;
/** SHA8 hash of the brain MCP endpoint URL (or 'local' for on-disk engines). */
endpoint_hash: string;
/** Per-entity last-refresh epoch ms. Absent → never refreshed. */
last_refresh: Record<string, number>;
/** Per-entity last-attempt epoch ms (even if attempt failed). For stale-but-usable diagnostics. */
last_attempt?: Record<string, number>;
}
/** Returns the directory holding a given entity's cache file. */
export function entityDir(entity: BrainCacheEntity, projectSlug: string | null): string {
if (entity.scope === 'cross-project') {
return join(GSTACK_HOME, 'brain-cache');
}
if (!projectSlug) {
throw new Error(`Per-project entity needs a project slug: ${entity.file}`);
}
return join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache');
}
/** Returns the path to the cache file for a given entity. */
export function entityPath(entityName: string, projectSlug: string | null): string {
const entity = BRAIN_CACHE_ENTITIES[entityName];
if (!entity) throw new Error(`Unknown brain cache entity: ${entityName}`);
return join(entityDir(entity, projectSlug), entity.file);
}
/** Returns the path to the _meta.json for a given scope. */
export function metaPath(scope: 'cross-project' | 'per-project', projectSlug: string | null): string {
if (scope === 'cross-project') {
return join(GSTACK_HOME, 'brain-cache', '_meta.json');
}
if (!projectSlug) throw new Error('Per-project meta needs a project slug');
return join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache', '_meta.json');
}
function loadMeta(scope: 'cross-project' | 'per-project', projectSlug: string | null): CacheMeta {
const path = metaPath(scope, projectSlug);
if (!existsSync(path)) {
return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
}
try {
return JSON.parse(readFileSync(path, 'utf-8')) as CacheMeta;
} catch {
// Corrupt _meta — start fresh (entries will refresh on next access).
return { schema_version: GSTACK_SCHEMA_PACK_VERSION, endpoint_hash: detectEndpointHash(), last_refresh: {}, last_attempt: {} };
}
}
function saveMeta(scope: 'cross-project' | 'per-project', projectSlug: string | null, meta: CacheMeta): void {
const path = metaPath(scope, projectSlug);
mkdirSync(dirname(path), { recursive: true });
atomicWrite(path, JSON.stringify(meta, null, 2));
}
// ──────────────────────────────────────────────────────────────────────────
// Endpoint hash detection
// ──────────────────────────────────────────────────────────────────────────
import { createHash } from 'crypto';
function sha8(input: string): string {
return createHash('sha256').update(input).digest('hex').slice(0, 8);
}
/**
* Detects the active brain endpoint (MCP URL or 'local') and returns its
* stable identity hash. Used to detect when the user switches brains
* (different endpoint → different cache).
*/
export function detectEndpointHash(): string {
const claudeJsonPath = join(homedir(), '.claude.json');
if (existsSync(claudeJsonPath)) {
try {
const cfg = JSON.parse(readFileSync(claudeJsonPath, 'utf-8'));
const gbrainServer = cfg?.mcpServers?.gbrain;
const url = gbrainServer?.url || gbrainServer?.transport?.url;
if (typeof url === 'string' && url.length > 0) {
return sha8(url);
}
} catch { /* fall through to local */ }
}
// Local engine — no endpoint URL; use a stable literal hash.
return 'local';
}
// ──────────────────────────────────────────────────────────────────────────
// Atomic write (tmp + rename)
// ──────────────────────────────────────────────────────────────────────────
function atomicWrite(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
writeFileSync(tmp, content, 'utf-8');
renameSync(tmp, path);
}
// ──────────────────────────────────────────────────────────────────────────
// Staleness + refresh logic
// ──────────────────────────────────────────────────────────────────────────
/** Returns true if the cached digest is past its TTL. */
function isStale(entityName: string, meta: CacheMeta): boolean {
const entity = BRAIN_CACHE_ENTITIES[entityName];
if (!entity) return true;
const last = meta.last_refresh[entityName];
if (!last) return true;
return Date.now() - last > entity.ttl_ms;
}
/** Returns true if the cache file exists on disk. */
function hasFile(entityName: string, projectSlug: string | null): boolean {
return existsSync(entityPath(entityName, projectSlug));
}
/** Returns true if schema version recorded in meta differs from current pack version. */
function schemaVersionMismatch(meta: CacheMeta): boolean {
return meta.schema_version !== GSTACK_SCHEMA_PACK_VERSION;
}
/** Returns true if endpoint hash recorded in meta differs from current detected endpoint. */
function endpointSwitched(meta: CacheMeta): boolean {
return meta.endpoint_hash !== detectEndpointHash();
}
// ──────────────────────────────────────────────────────────────────────────
// Subcommand: get
// ──────────────────────────────────────────────────────────────────────────
interface GetResult {
/** Path to the digest file. */
path: string;
/** Cache state: 'warm' (fresh + valid), 'cold-refreshed' (was stale, refreshed inline), 'stale-fallback' (used stale because refresh failed), 'missing' (no cache and no refresh). */
state: 'warm' | 'cold-refreshed' | 'stale-fallback' | 'missing';
/** Optional message for diagnostics. */
message?: string;
}
export function cmdGet(entityName: string, projectSlug: string | null): GetResult {
const entity = BRAIN_CACHE_ENTITIES[entityName];
if (!entity) throw new Error(`Unknown entity: ${entityName}`);
const scope = entity.scope;
const meta = loadMeta(scope, projectSlug);
// Schema-version mismatch → full rebuild (D4 A4).
if (schemaVersionMismatch(meta) || endpointSwitched(meta)) {
rebuildAllForScope(scope, projectSlug);
// After rebuild, meta is fresh; fall through to warm path.
const newMeta = loadMeta(scope, projectSlug);
if (hasFile(entityName, projectSlug) && !isStale(entityName, newMeta)) {
return { path: entityPath(entityName, projectSlug), state: 'warm' };
}
// Rebuild may have failed for this entity specifically.
return { path: entityPath(entityName, projectSlug), state: 'missing', message: 'rebuild after schema/endpoint change' };
}
if (hasFile(entityName, projectSlug) && !isStale(entityName, meta)) {
return { path: entityPath(entityName, projectSlug), state: 'warm' };
}
// Stale or missing — try cold refresh.
const refreshed = refreshEntity(entityName, projectSlug);
if (refreshed) {
return { path: entityPath(entityName, projectSlug), state: 'cold-refreshed' };
}
// Refresh failed. Use stale-but-usable if file exists.
if (hasFile(entityName, projectSlug)) {
return { path: entityPath(entityName, projectSlug), state: 'stale-fallback', message: 'brain unreachable; using stale cache' };
}
// No cache and no refresh = missing.
return { path: entityPath(entityName, projectSlug), state: 'missing', message: 'brain unreachable; no cache available' };
}
// ──────────────────────────────────────────────────────────────────────────
// Subcommand: refresh
// ──────────────────────────────────────────────────────────────────────────
// ──────────────────────────────────────────────────────────────────────────
// Lockfile dedup (T15 / D3)
// ──────────────────────────────────────────────────────────────────────────
/**
* Returns the lock file path for a project scope. Cross-project entities
* still lock per-project (the project triggering the refresh holds the lock);
* concurrent attempts from different projects on cross-project entities
* serialize naturally because they're rare and the lock window is short.
*/
function lockPath(projectSlug: string | null): string {
const dir = projectSlug
? join(GSTACK_HOME, 'projects', projectSlug, 'brain-cache')
: join(GSTACK_HOME, 'brain-cache');
return join(dir, '.refresh.lock');
}
interface LockHandle {
fd: number;
path: string;
}
/**
* Try to acquire the refresh lock. Returns null when another process holds it
* (and the lock is fresh). Stale locks (process dead OR older than the
* timeout) are taken over.
*/
function tryAcquireLock(projectSlug: string | null): LockHandle | null {
const path = lockPath(projectSlug);
mkdirSync(dirname(path), { recursive: true });
// If a lock exists, see if it's stale
if (existsSync(path)) {
try {
const raw = readFileSync(path, 'utf-8');
const lock = JSON.parse(raw) as { pid: number; host: string; ts: number };
const age = Date.now() - lock.ts;
const sameHost = lock.host === hostname();
const processGone = sameHost && lock.pid > 0 && !isPidAlive(lock.pid);
if (age <= CACHE_REFRESH_LOCK_TIMEOUT_MS && !processGone) {
return null; // someone else holds a fresh lock
}
// Stale: take over
} catch {
// Corrupt lock file → take over
}
}
// Write our lock (best-effort O_EXCL via tmp+rename for atomic creation)
const payload = JSON.stringify({ pid: process.pid, host: hostname(), ts: Date.now() });
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
try {
writeFileSync(tmp, payload);
renameSync(tmp, path);
} catch (err) {
return null;
}
// Race: another process may have raced us. Re-read and verify ownership.
try {
const raw = readFileSync(path, 'utf-8');
const lock = JSON.parse(raw) as { pid: number; host: string };
if (lock.pid !== process.pid || lock.host !== hostname()) {
return null;
}
} catch {
return null;
}
return { fd: -1, path };
}
function releaseLock(handle: LockHandle): void {
try { unlinkSync(handle.path); } catch { /* best effort */ }
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err: any) {
if (err?.code === 'EPERM') return true; // exists but we don't own it
return false;
}
}
/**
* Run a refresh callback under the project-scoped lock. If another refresh is
* already in flight, returns 'dedup' and the caller can either wait + retry
* (the resolver does this) or fall through to stale-but-usable. Stale locks
* (process dead, or older than CACHE_REFRESH_LOCK_TIMEOUT_MS) are taken over.
*/
export function withRefreshLock<T>(projectSlug: string | null, fn: () => T): T | 'dedup' {
const handle = tryAcquireLock(projectSlug);
if (!handle) return 'dedup';
try {
return fn();
} finally {
releaseLock(handle);
}
}
/** Refreshes one entity from the brain. Returns true on success. */
export function refreshEntity(entityName: string, projectSlug: string | null): boolean {
const entity = BRAIN_CACHE_ENTITIES[entityName];
if (!entity) return false;
// Mark attempt
const meta = loadMeta(entity.scope, projectSlug);
meta.last_attempt = meta.last_attempt || {};
meta.last_attempt[entityName] = Date.now();
// Fetch from brain. The actual fetch logic varies per entity — derived digests
// (recent-decisions, salience) need different queries from direct page reads.
// For T2a we implement the direct-page path; derived digests get filled in by
// the resolver / write-back paths in later commits.
const digestContent = fetchAndCompressEntity(entityName, projectSlug);
if (digestContent === null) {
saveMeta(entity.scope, projectSlug, meta);
return false;
}
// Enforce per-entity budget by truncating from end (oldest items live there
// by convention in our compressor). The per-skill budget is separately
// enforced at preflight injection time.
let final = digestContent;
if (Buffer.byteLength(final, 'utf-8') > entity.budget_bytes) {
final = truncateToBudget(final, entity.budget_bytes);
}
atomicWrite(entityPath(entityName, projectSlug), final);
meta.last_refresh[entityName] = Date.now();
// Keep schema/endpoint identity fresh.
meta.schema_version = GSTACK_SCHEMA_PACK_VERSION;
meta.endpoint_hash = detectEndpointHash();
saveMeta(entity.scope, projectSlug, meta);
return true;
}
/**
* Refresh all entities for a scope (per-project or cross-project).
* Used by --full and by schema/endpoint-change rebuilds.
*/
export function refreshAll(projectSlug: string | null): { success: number; failed: number } {
let success = 0;
let failed = 0;
for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
// Cross-project entities only refresh when explicitly targeted via no-slug calls
if (entity.scope === 'cross-project' && projectSlug) continue;
if (entity.scope === 'per-project' && !projectSlug) continue;
if (refreshEntity(name, projectSlug)) success++; else failed++;
}
return { success, failed };
}
/** Rebuild on schema-version mismatch or endpoint switch. Wipes affected scope first. */
function rebuildAllForScope(scope: 'cross-project' | 'per-project', projectSlug: string | null): void {
// Wipe files but preserve dir; meta gets fully rewritten by refreshes below.
for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
if (entity.scope !== scope) continue;
const p = entityPath(name, projectSlug);
if (existsSync(p)) {
try { unlinkSync(p); } catch { /* best effort */ }
}
}
// Fresh meta starts here
const fresh: CacheMeta = {
schema_version: GSTACK_SCHEMA_PACK_VERSION,
endpoint_hash: detectEndpointHash(),
last_refresh: {},
last_attempt: {},
};
saveMeta(scope, projectSlug, fresh);
// Refresh all entities in this scope
for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
if (entity.scope !== scope) continue;
refreshEntity(name, projectSlug);
}
}
// ──────────────────────────────────────────────────────────────────────────
// Subcommand: invalidate
// ──────────────────────────────────────────────────────────────────────────
export function cmdInvalidate(entityName: string, projectSlug: string | null): void {
const entity = BRAIN_CACHE_ENTITIES[entityName];
if (!entity) throw new Error(`Unknown entity: ${entityName}`);
const meta = loadMeta(entity.scope, projectSlug);
delete meta.last_refresh[entityName];
saveMeta(entity.scope, projectSlug, meta);
}
// ──────────────────────────────────────────────────────────────────────────
// Fetch + compress per-entity
// ──────────────────────────────────────────────────────────────────────────
/**
* Returns the digest markdown content for an entity, or null if the brain is
* unreachable / the source page doesn't exist.
*
* For T2a we implement the entity → page-slug mapping for the simple cases.
* Derived digests (recent-decisions, salience) get specialized paths.
*/
function fetchAndCompressEntity(entityName: string, projectSlug: string | null): string | null {
switch (entityName) {
case 'user-profile':
return fetchUserProfile();
case 'product':
return fetchProduct(projectSlug);
case 'goals':
return fetchGoals(projectSlug);
case 'developer-persona':
return fetchSimplePage(`gstack/developer-persona/${projectSlug}`);
case 'brand':
return fetchSimplePage(`gstack/brand/${projectSlug}`);
case 'competitive-intel':
return fetchSimplePage(`gstack/competitive-intel/${projectSlug}`);
case 'recent-decisions':
return fetchRecentDecisions(projectSlug);
case 'salience':
// D9 salience allowlist applied in T17 commit; T2a returns raw output for now.
return fetchSalience(projectSlug);
default:
return null;
}
}
/** Generic single-page fetch via `gbrain get`. Returns null on miss/unreachable. */
function fetchSimplePage(slug: string): string | null {
const result = spawnGbrain(['get', slug, '--json'], { timeout: 10_000 });
if (result.status !== 0) return null;
try {
const page = JSON.parse(result.stdout) as { body?: string; title?: string };
if (!page?.body) return null;
return compressPage(slug, page.title || slug, page.body);
} catch {
return null;
}
}
function fetchUserProfile(): string | null {
// The user-slug discovery is implemented in T16 (D4 A3). For T2a we accept
// env GSTACK_USER_SLUG as override, fallback to $USER for direct calls.
const slug = process.env.GSTACK_USER_SLUG || process.env.USER || 'unknown';
return fetchSimplePage(`gstack/user-profile/${slug}`);
}
function fetchProduct(projectSlug: string | null): string | null {
if (!projectSlug) return null;
return fetchSimplePage(`gstack/product/${projectSlug}`);
}
/**
* Goals are LIST queries: all gstack/goal/<project>/* pages.
* Compress the top N by recency.
*/
function fetchGoals(projectSlug: string | null): string | null {
if (!projectSlug) return null;
const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; body?: string }> }>([
'list-pages',
'--type', 'gstack/goal',
'--limit', '10',
'--json',
]);
if (!result?.pages) return null;
const goals = result.pages.filter((p) => p.slug?.startsWith(`gstack/goal/${projectSlug}/`));
if (goals.length === 0) {
// Empty digest is valid (just header + 'no active goals' line)
return `# Active goals (project: ${projectSlug})\n\n_No active goals recorded yet._\n`;
}
const lines = goals.map((g) => `- [[${g.slug}]] — ${g.title || '(untitled)'}`);
return `# Active goals (project: ${projectSlug})\n\n${lines.join('\n')}\n`;
}
/**
* recent-decisions: last 5 gstack/skill-run pages for this project, compressed
* to one-line summaries.
*/
function fetchRecentDecisions(projectSlug: string | null): string | null {
if (!projectSlug) return null;
const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string }> }>([
'list-pages',
'--type', 'gstack/skill-run',
'--limit', '5',
'--sort', 'updated_desc',
'--json',
]);
if (!result?.pages) {
return `# Recent decisions (project: ${projectSlug})\n\n_No prior skill runs recorded._\n`;
}
const lines = result.pages.map((p) => `- ${p.title || p.slug}`);
return `# Recent decisions (project: ${projectSlug})\n\n${lines.join('\n')}\n`;
}
/**
* Reads the user's salience allowlist override from gstack-config. If unset,
* returns SALIENCE_DEFAULT_ALLOWLIST. The override is comma-separated; we
* trim and drop empty entries.
*/
export function getSalienceAllowlist(): ReadonlyArray<string> {
// Short-circuit via env var for tests + headless callers.
const env = process.env.GSTACK_SALIENCE_ALLOWLIST;
if (typeof env === 'string' && env.length > 0) {
return env.split(',').map((s) => s.trim()).filter(Boolean);
}
// Shell out to gstack-config with a tight timeout. Falls back to defaults
// on any failure (config script missing, command non-zero, parse error).
try {
const skillRoot = join(homedir(), '.claude', 'skills', 'gstack');
const bin = join(skillRoot, 'bin', 'gstack-config');
if (!existsSync(bin)) return SALIENCE_DEFAULT_ALLOWLIST;
const result = spawnSync(bin, ['get', 'salience_allowlist'], { timeout: 2000, encoding: 'utf-8' });
if (result.status !== 0 || !result.stdout) return SALIENCE_DEFAULT_ALLOWLIST;
const trimmed = result.stdout.trim();
if (!trimmed) return SALIENCE_DEFAULT_ALLOWLIST;
const parts = trimmed.split(',').map((s) => s.trim()).filter(Boolean);
return parts.length > 0 ? parts : SALIENCE_DEFAULT_ALLOWLIST;
} catch {
return SALIENCE_DEFAULT_ALLOWLIST;
}
}
/**
* D9 salience privacy gate: returns true if the slug starts with any allowlisted
* prefix. Anything NOT matching is stripped at digest write time so that family,
* therapy, reflection, and other sensitive content never leaks into work-flow
* planning prompts by default.
*/
export function isSalienceSlugAllowed(slug: string, allowlist: ReadonlyArray<string>): boolean {
for (const prefix of allowlist) {
if (slug.startsWith(prefix)) return true;
}
return false;
}
function fetchSalience(projectSlug: string | null): string | null {
// get-recent-salience is a gbrain CLI sub-shape; we use the MCP-shape JSON
const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string; emotional_weight?: number }> }>([
'get-recent-salience',
'--days', '14',
'--limit', '10',
'--json',
]);
if (!result?.pages) return `# Recent salience\n\n_No salient pages in last 14d._\n`;
// D9 privacy gate: strip entries outside the allowlist BEFORE rendering.
// Sensitive personal content (family, therapy, reflection) is never written
// into the digest cache file, even when the brain itself ranks it salient.
const allowlist = getSalienceAllowlist();
const filtered = result.pages.filter((p) => p.slug && isSalienceSlugAllowed(p.slug, allowlist));
const stripped = result.pages.length - filtered.length;
if (filtered.length === 0) {
const header = `# Recent salience (last 14d)`;
const note = stripped > 0
? `\n_All ${stripped} salient entries stripped by allowlist gate (no work-flow content in window)._\n`
: `\n_No salient pages in last 14d._\n`;
return `${header}\n${note}`;
}
const lines = filtered.map((p) => `- [[${p.slug}]] — ${p.title || ''} (weight: ${p.emotional_weight?.toFixed(2) ?? 'n/a'})`);
const footer = stripped > 0
? `\n\n_${stripped} private entries stripped by allowlist gate._`
: '';
return `# Recent salience (last 14d)\n\n${lines.join('\n')}${footer}\n`;
}
/**
* Compress a brain page body into a digest. The compressor keeps frontmatter
* out, trims body to the first H2/H3 sections, and prepends a slug header.
* Per-entity budget enforcement happens at the caller (refreshEntity).
*/
function compressPage(slug: string, title: string, body: string): string {
const trimmed = body
.replace(/^---[\s\S]*?---\s*\n/m, '') // strip frontmatter
.trim();
return `# ${title}\nslug: ${slug}\n\n${trimmed}\n`;
}
/**
* Truncate a digest to a byte budget. Tries to cut at the last newline before
* the budget so the digest stays readable.
*/
function truncateToBudget(content: string, budgetBytes: number): string {
const buf = Buffer.from(content, 'utf-8');
if (buf.byteLength <= budgetBytes) return content;
const truncated = buf.slice(0, budgetBytes).toString('utf-8');
const lastNewline = truncated.lastIndexOf('\n');
const cleanCut = lastNewline > budgetBytes * 0.8 ? truncated.slice(0, lastNewline) : truncated;
return `${cleanCut}\n\n_(digest truncated to ${budgetBytes}-byte budget)_\n`;
}
// ──────────────────────────────────────────────────────────────────────────
// Subcommand: digest
// ──────────────────────────────────────────────────────────────────────────
/**
* Public: compress a brain page slug to digest format. Used by callers that
* want to know what the digest WOULD look like without writing to cache.
*/
export function cmdDigest(slug: string): string | null {
return fetchSimplePage(slug);
}
// ──────────────────────────────────────────────────────────────────────────
// Subcommand: meta
// ──────────────────────────────────────────────────────────────────────────
export function cmdMeta(projectSlug: string | null): CacheMeta {
if (projectSlug) return loadMeta('per-project', projectSlug);
return loadMeta('cross-project', null);
}
// ──────────────────────────────────────────────────────────────────────────
// Subcommand: bootstrap (T2b)
// ──────────────────────────────────────────────────────────────────────────
/**
* Bootstrap synthesizes draft entity content from CLAUDE.md + README +
* recent commits + learnings.jsonl for a fresh project. Emits as JSON for
* the caller (skill template) to AUQ-confirm before any write to the brain.
*
* This keeps the CLI pure (no AUQ logic) while preventing silent
* auto-extraction garbage (D10 T4 fix). The agent is responsible for the
* "Synthesized X — looks right?" prompt per entity.
*/
export interface BootstrapDraft {
product?: { slug: string; title: string; body: string };
goals?: Array<{ slug: string; title: string; body: string }>;
developer_persona?: { slug: string; title: string; body: string };
brand?: { slug: string; title: string; body: string };
competitive_intel?: { slug: string; title: string; body: string };
}
export function cmdBootstrap(projectSlug: string): BootstrapDraft {
const draft: BootstrapDraft = {};
const repoRoot = process.env.GSTACK_REPO_ROOT || process.cwd();
// Product synthesis: CLAUDE.md headline + README first paragraph
let claudeMd = '';
try { claudeMd = readFileSync(join(repoRoot, 'CLAUDE.md'), 'utf-8'); } catch { /* missing is fine */ }
let readmeMd = '';
try { readmeMd = readFileSync(join(repoRoot, 'README.md'), 'utf-8'); } catch { /* missing is fine */ }
const productLead = synthesizeProductLead(claudeMd, readmeMd, projectSlug);
if (productLead) {
draft.product = {
slug: `gstack/product/${projectSlug}`,
title: projectSlug,
body: productLead,
};
}
// Goals: try learnings.jsonl + recent commit messages mentioning "goal" or "ship"
const learningsPath = join(GSTACK_HOME, 'projects', projectSlug, 'learnings.jsonl');
const goalsHints = synthesizeGoalsHints(learningsPath, repoRoot);
if (goalsHints.length > 0) {
draft.goals = goalsHints.slice(0, 3).map((hint, idx) => ({
slug: `gstack/goal/${projectSlug}/bootstrap-${idx + 1}`,
title: hint.title,
body: hint.body,
}));
}
return draft;
}
function synthesizeProductLead(claudeMd: string, readmeMd: string, slug: string): string | null {
// First H1 in CLAUDE.md or README, plus first paragraph after it.
const source = claudeMd || readmeMd;
if (!source) return null;
const h1Match = source.match(/^#\s+(.+)$/m);
const heading = h1Match?.[1]?.trim() || slug;
// First non-heading paragraph
const paraMatch = source.match(/(?:^|\n)([^#\n][^\n]+(?:\n[^#\n][^\n]+)*)/);
const lead = paraMatch?.[1]?.trim() || '(no description found in CLAUDE.md or README)';
return [
`# ${heading}`,
'',
'## What',
lead.slice(0, 500),
'',
'## Stage',
'(fill in current stage, e.g., v1.x shipped, in development, paused)',
'',
'## Team',
'(fill in team composition + size)',
'',
'## Active goals',
'(populated by /office-hours over time)',
'',
'## Recent decisions',
'(populated by /plan-ceo-review over time)',
'',
].join('\n');
}
function synthesizeGoalsHints(learningsPath: string, repoRoot: string): Array<{ title: string; body: string }> {
const hints: Array<{ title: string; body: string }> = [];
if (existsSync(learningsPath)) {
try {
const lines = readFileSync(learningsPath, 'utf-8').split('\n').filter(Boolean);
for (const line of lines.slice(-10)) {
try {
const entry = JSON.parse(line);
if (entry?.insight && (entry?.type === 'pattern' || entry?.type === 'architecture')) {
hints.push({
title: entry.insight.slice(0, 80),
body: `Source: learnings.jsonl\nType: ${entry.type}\n\n${entry.insight}\n`,
});
}
} catch { /* skip malformed line */ }
}
} catch { /* unreadable file, skip */ }
}
return hints;
}
// ──────────────────────────────────────────────────────────────────────────
// Subcommand: list (T18)
// ──────────────────────────────────────────────────────────────────────────
/**
* Lists all gstack-owned pages currently in the brain for a project, grouped
* by type. Powers the user's ability to audit what gstack has written.
*/
export function cmdList(projectSlug: string | null): Array<{ type: string; slug: string; title?: string }> {
// We probe each gstack/<type>/ namespace via list-pages with a type filter.
const types = ['gstack/user-profile', 'gstack/product', 'gstack/goal', 'gstack/developer-persona', 'gstack/brand', 'gstack/competitive-intel', 'gstack/skill-run', 'gstack/take'];
const all: Array<{ type: string; slug: string; title?: string }> = [];
for (const type of types) {
const result = execGbrainJson<{ pages?: Array<{ slug: string; title?: string }> }>([
'list-pages',
'--type', type,
'--limit', '200',
'--json',
]);
if (!result?.pages) continue;
for (const page of result.pages) {
if (projectSlug && !page.slug?.includes(`/${projectSlug}`) && type !== 'gstack/user-profile') {
continue;
}
all.push({ type, slug: page.slug, title: page.title });
}
}
return all;
}
// ──────────────────────────────────────────────────────────────────────────
// Subcommand: purge (T18)
// ──────────────────────────────────────────────────────────────────────────
/**
* Delete one gstack-owned page from the brain. Caller (skill template) is
* responsible for the confirm prompt; this is the raw operation.
*/
export function cmdPurge(slug: string): { deleted: boolean; error?: string } {
if (!slug.startsWith('gstack/')) {
return { deleted: false, error: 'refusing to purge non-gstack page' };
}
const result = spawnGbrain(['delete-page', slug], { timeout: 10_000 });
if (result.status !== 0) {
return { deleted: false, error: result.stderr?.trim() || `exit ${result.status}` };
}
// Also invalidate any cached digests that referenced this page.
// Best-effort — derived digests may need explicit invalidate.
return { deleted: true };
}
// ──────────────────────────────────────────────────────────────────────────
// CLI dispatch
// ──────────────────────────────────────────────────────────────────────────
function parseArgs(argv: string[]): { cmd: string; positional: string[]; flags: Record<string, string | boolean> } {
const cmd = argv[2] || '';
const rest = argv.slice(3);
const positional: string[] = [];
const flags: Record<string, string | boolean> = {};
for (let i = 0; i < rest.length; i++) {
const arg = rest[i];
if (arg.startsWith('--')) {
const key = arg.slice(2);
const next = rest[i + 1];
if (next && !next.startsWith('--')) {
flags[key] = next;
i++;
} else {
flags[key] = true;
}
} else {
positional.push(arg);
}
}
return { cmd, positional, flags };
}
function projectSlugFromFlag(flags: Record<string, string | boolean>): string | null {
const v = flags.project;
return typeof v === 'string' ? v : null;
}
function printUsage(): void {
process.stderr.write(`Usage: gstack-brain-cache <subcommand>
Subcommands:
get <entity-name> [--project <slug>]
refresh [--full] [--entity X] [--project <slug>]
invalidate <entity-name> [--project <slug>]
digest <entity-slug>
meta [--project <slug>]
bootstrap --project <slug> — emit synthesized entity drafts (JSON)
list [--project <slug>] — list gstack-owned pages in brain
purge <slug> — delete a gstack-owned brain page (refuses non-gstack/ slugs)
`);
}
async function main(): Promise<number> {
const { cmd, positional, flags } = parseArgs(process.argv);
const projectSlug = projectSlugFromFlag(flags);
try {
switch (cmd) {
case 'get': {
const entityName = positional[0];
if (!entityName) { printUsage(); return 1; }
const result = cmdGet(entityName, projectSlug);
if (result.state === 'missing') {
process.stderr.write(`(${result.state}: ${result.message ?? 'no cache'})\n`);
return 2;
}
if (result.state !== 'warm') {
process.stderr.write(`(${result.state}${result.message ? ': ' + result.message : ''})\n`);
}
process.stdout.write(readFileSync(result.path, 'utf-8'));
return 0;
}
case 'refresh': {
// D3: dedup concurrent refreshes via lockfile. Skipped (dedup) when
// another process is already mid-refresh on the same project.
if (flags.entity) {
const entityName = String(flags.entity);
const result = withRefreshLock(projectSlug, () => refreshEntity(entityName, projectSlug));
if (result === 'dedup') {
process.stderr.write(`(dedup: another refresh in flight)\n`);
return 3;
}
process.stdout.write(result ? `refreshed ${entityName}\n` : `failed to refresh ${entityName}\n`);
return result ? 0 : 1;
}
const allResult = withRefreshLock(projectSlug, () => refreshAll(projectSlug));
if (allResult === 'dedup') {
process.stderr.write(`(dedup: another refresh in flight)\n`);
return 3;
}
process.stdout.write(`refreshed=${allResult.success} failed=${allResult.failed}\n`);
return allResult.failed > 0 ? 1 : 0;
}
case 'invalidate': {
const entityName = positional[0];
if (!entityName) { printUsage(); return 1; }
cmdInvalidate(entityName, projectSlug);
process.stdout.write(`invalidated ${entityName}\n`);
return 0;
}
case 'digest': {
const slug = positional[0];
if (!slug) { printUsage(); return 1; }
const content = cmdDigest(slug);
if (content === null) {
process.stderr.write('brain unreachable or page not found\n');
return 2;
}
process.stdout.write(content);
return 0;
}
case 'meta': {
const meta = cmdMeta(projectSlug);
process.stdout.write(JSON.stringify(meta, null, 2) + '\n');
return 0;
}
case 'bootstrap': {
if (!projectSlug) {
process.stderr.write('bootstrap requires --project <slug>\n');
return 1;
}
const draft = cmdBootstrap(projectSlug);
process.stdout.write(JSON.stringify(draft, null, 2) + '\n');
return 0;
}
case 'list': {
const pages = cmdList(projectSlug);
if (flags.json) {
process.stdout.write(JSON.stringify(pages, null, 2) + '\n');
} else {
for (const p of pages) {
process.stdout.write(`${p.type}\t${p.slug}\t${p.title ?? ''}\n`);
}
}
return 0;
}
case 'purge': {
const slug = positional[0];
if (!slug) { printUsage(); return 1; }
const result = cmdPurge(slug);
if (result.deleted) {
process.stdout.write(`deleted ${slug}\n`);
return 0;
}
process.stderr.write(`failed: ${result.error}\n`);
return 1;
}
case '':
case 'help':
case '--help':
case '-h':
printUsage();
return 0;
default:
process.stderr.write(`unknown subcommand: ${cmd}\n`);
printUsage();
return 1;
}
} catch (err) {
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
return 1;
}
}
// Only run main when invoked as a script (not when imported by tests)
if (import.meta.main) {
main().then((code) => process.exit(code));
}
+221 -10
View File
@@ -75,6 +75,16 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
# # Set to true once the privacy gate has asked the user.
# # Flip back to false to be re-prompted.
#
# ─── Plan-tune hooks ─────────────────────────────────────────────────
# plan_tune_hooks: prompt # Controls whether ./setup installs the plan-tune
# # Claude Code hooks (PostToolUse capture +
# # PreToolUse preference enforcement).
# # prompt — ask on a real TTY, skip otherwise (default)
# # yes — install non-interactively
# # no — skip non-interactively
# # Override per-run: ./setup --plan-tune-hooks /
# # --no-plan-tune-hooks, or env GSTACK_PLAN_TUNE_HOOKS.
#
# ─── Advanced ────────────────────────────────────────────────────────
# codex_reviews: enabled # disabled = skip Codex adversarial reviews in /ship
# gstack_contributor: false # true = file field reports when gstack misbehaves
@@ -110,19 +120,145 @@ lookup_default() {
cross_project_learnings) echo "" ;; # intentionally empty → unset triggers first-time prompt
artifacts_sync_mode) echo "off" ;;
artifacts_sync_mode_prompted) echo "false" ;;
plan_tune_hooks) echo "prompt" ;; # prompt | yes | no — controls ./setup plan-tune hook install
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
redact_prepush_hook) echo "false" ;;
# Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline:
# brain_trust_policy@<hash> — unset on fresh install; setup-gbrain
# writes 'personal' for local engines,
# asks the user for remote-ambiguous.
# salience_allowlist — empty falls through to
# SALIENCE_DEFAULT_ALLOWLIST (D9).
# user_slug_at_<hash> — empty triggers resolve-user-slug
# fallback chain (D4 A3) on first call.
brain_trust_policy*) echo "unset" ;;
salience_allowlist) echo "" ;;
user_slug_at_*) echo "" ;;
*) echo "" ;;
esac
}
# ──────────────────────────────────────────────────────────────────────
# Brain-integration helpers (T5+T10+T16)
# ──────────────────────────────────────────────────────────────────────
# Compute sha8 of a string. Used for endpoint hashing.
sha8_of() {
printf '%s' "$1" | shasum -a 256 | cut -c1-8
}
# Detect the active brain endpoint hash. Reads ~/.claude.json for the gbrain
# MCP server URL. Falls back to the literal 'local' when no MCP is configured.
endpoint_hash() {
_claude_json="$HOME/.claude.json"
if [ -f "$_claude_json" ] && command -v jq >/dev/null 2>&1; then
_url=$(jq -r '.mcpServers.gbrain.url // .mcpServers.gbrain.transport.url // empty' "$_claude_json" 2>/dev/null)
if [ -n "$_url" ] && [ "$_url" != "null" ]; then
sha8_of "$_url"
return 0
fi
fi
printf '%s' "local"
}
# Detect endpoint hash collisions. When two distinct endpoints share the same
# sha8 prefix (rare but possible), escalate to sha16 by emitting the longer
# hash. Detection: scan config file for existing brain_trust_policy@<hash> or
# user_slug_at_<hash> keys; if any non-active hash equals the active sha8 but
# would differ at sha16, the active endpoint needs sha16.
endpoint_hash_with_collision_check() {
_active=$(endpoint_hash)
if [ "$_active" = "local" ]; then
printf '%s' "$_active"
return 0
fi
# If a different endpoint (different URL) shares this sha8, escalate.
# We only catch this when the config has another endpoint recorded.
_matching=$(grep -E "^(brain_trust_policy|user_slug_at)@${_active}" "$CONFIG_FILE" 2>/dev/null | head -1 || true)
_claude_json="$HOME/.claude.json"
if [ -n "$_matching" ] && [ -f "$_claude_json" ] && command -v jq >/dev/null 2>&1; then
_url=$(jq -r '.mcpServers.gbrain.url // .mcpServers.gbrain.transport.url // empty' "$_claude_json" 2>/dev/null)
_sha16=$(printf '%s' "$_url" | shasum -a 256 | cut -c1-16)
# Look for any sha16-namespaced key that conflicts. If a stored sha16 exists
# and differs from current sha16, that's the collision evidence; emit sha16.
_stored16=$(grep -E "^(brain_trust_policy|user_slug_at)@${_sha16}" "$CONFIG_FILE" 2>/dev/null | head -1 || true)
if [ -n "$_stored16" ]; then
printf '%s' "$_sha16"
return 0
fi
fi
printf '%s' "$_active"
}
# Resolve the user-slug per D4 A3 chain:
# 1. mcp__gbrain__whoami.client_name (best effort via gbrain CLI shell-out)
# 2. $USER env
# 3. sha8($(git config user.email))
# 4. anonymous-<sha8(hostname)>
# Persists result via gstack-config set user_slug_at_<endpoint-hash> on first call.
resolve_user_slug() {
_hash=$(endpoint_hash_with_collision_check)
_stored=$(grep -E "^user_slug_at_${_hash}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
if [ -n "$_stored" ]; then
printf '%s' "$_stored"
return 0
fi
_slug=""
# Layer 1: gbrain whoami
if command -v gbrain >/dev/null 2>&1; then
_whoami=$(gbrain whoami --json 2>/dev/null || true)
if [ -n "$_whoami" ] && command -v jq >/dev/null 2>&1; then
_client_name=$(printf '%s' "$_whoami" | jq -r '.client_name // .token_name // empty' 2>/dev/null || true)
if [ -n "$_client_name" ] && [ "$_client_name" != "null" ]; then
_slug=$(printf '%s' "$_client_name" | tr '[:upper:] ' '[:lower:]-' | tr -dc '[:alnum:]-')
fi
fi
fi
# Layer 2: $USER
if [ -z "$_slug" ] && [ -n "${USER:-}" ]; then
_slug=$(printf '%s' "$USER" | tr '[:upper:] ' '[:lower:]-' | tr -dc '[:alnum:]-')
fi
# Layer 3: sha8 of git email
if [ -z "$_slug" ]; then
_email=$(git config user.email 2>/dev/null || true)
if [ -n "$_email" ]; then
_slug="email-$(sha8_of "$_email")"
fi
fi
# Layer 4: anonymous-<sha8(hostname)>
if [ -z "$_slug" ]; then
_slug="anonymous-$(sha8_of "$(hostname 2>/dev/null || echo unknown)")"
fi
# Persist via direct file write (avoid recursion into gstack-config set)
mkdir -p "$STATE_DIR"
if [ ! -f "$CONFIG_FILE" ]; then
printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE"
fi
if ! grep -qE "^user_slug_at_${_hash}:" "$CONFIG_FILE" 2>/dev/null; then
echo "user_slug_at_${_hash}: ${_slug}" >> "$CONFIG_FILE"
fi
printf '%s' "$_slug"
}
case "${1:-}" in
get)
KEY="${2:?Usage: gstack-config get <key>}"
# Validate key (alphanumeric + underscore only)
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+$'; then
echo "Error: key must contain only alphanumeric characters and underscores" >&2
# Validate key (alphanumeric + underscore + optional @<hash> suffix for
# endpoint-namespaced keys introduced by the brain-aware planning layer)
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-f0-9]+)?$'; then
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<hex-hash> suffix" >&2
exit 1
fi
VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
# Use literal match for keys containing @ (sha hashes), regex otherwise
VALUE=$(grep -F "${KEY}:" "$CONFIG_FILE" 2>/dev/null | grep -E "^${KEY%@*}(@[a-f0-9]+)?:" | grep -F "${KEY}:" | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
if [ -z "$VALUE" ]; then
VALUE=$(lookup_default "$KEY")
fi
@@ -131,11 +267,17 @@ case "${1:-}" in
set)
KEY="${2:?Usage: gstack-config set <key> <value>}"
VALUE="${3:?Usage: gstack-config set <key> <value>}"
# Validate key (alphanumeric + underscore only)
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+$'; then
echo "Error: key must contain only alphanumeric characters and underscores" >&2
# Validate key (alphanumeric + underscore + optional @<hash> suffix)
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-f0-9]+)?$'; then
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<hex-hash> suffix" >&2
exit 1
fi
# Validate brain_trust_policy value domain (D4 / D11)
if printf '%s' "$KEY" | grep -qE '^brain_trust_policy(@|$)' && \
[ "$VALUE" != "personal" ] && [ "$VALUE" != "shared" ] && [ "$VALUE" != "unset" ]; then
echo "Warning: brain_trust_policy '$VALUE' not recognized. Valid values: personal, shared, unset. Using unset." >&2
VALUE="unset"
fi
# V1: whitelist values for keys with closed value domains. Unknown values warn + default.
if [ "$KEY" = "explain_level" ] && [ "$VALUE" != "default" ] && [ "$VALUE" != "terse" ]; then
echo "Warning: explain_level '$VALUE' not recognized. Valid values: default, terse. Using default." >&2
@@ -145,6 +287,21 @@ case "${1:-}" in
echo "Warning: artifacts_sync_mode '$VALUE' not recognized. Valid values: off, artifacts-only, full. Using off." >&2
VALUE="off"
fi
# redact_repo_visibility: a LOCAL override for repos gh/glab can't read (e.g.
# self-hosted GitLab). It lives in ~/.gstack/config.yaml (never committed), so
# it can't be used to weaken the gate repo-wide for other contributors.
if [ "$KEY" = "redact_repo_visibility" ] && [ "$VALUE" != "public" ] && [ "$VALUE" != "private" ] && [ "$VALUE" != "unknown" ]; then
echo "Warning: redact_repo_visibility '$VALUE' not recognized. Valid values: public, private, unknown. Using unknown." >&2
VALUE="unknown"
fi
if [ "$KEY" = "redact_prepush_hook" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then
echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: true, false. Using false." >&2
VALUE="false"
fi
if [ "$KEY" = "plan_tune_hooks" ] && [ "$VALUE" != "prompt" ] && [ "$VALUE" != "yes" ] && [ "$VALUE" != "no" ]; then
echo "Warning: plan_tune_hooks '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2
VALUE="prompt"
fi
mkdir -p "$STATE_DIR"
# Write annotated header on first creation
if [ ! -f "$CONFIG_FILE" ]; then
@@ -174,7 +331,7 @@ case "${1:-}" in
for KEY in proactive routing_declined telemetry auto_upgrade update_check \
skill_prefix checkpoint_mode checkpoint_push explain_level \
codex_reviews gstack_contributor skip_eng_review workspace_root \
artifacts_sync_mode artifacts_sync_mode_prompted; do
artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do
VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
SOURCE="default"
if [ -n "$VALUE" ]; then
@@ -190,12 +347,66 @@ case "${1:-}" in
for KEY in proactive routing_declined telemetry auto_upgrade update_check \
skill_prefix checkpoint_mode checkpoint_push explain_level \
codex_reviews gstack_contributor skip_eng_review workspace_root \
artifacts_sync_mode artifacts_sync_mode_prompted; do
artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
done
;;
endpoint-hash)
# Brain integration helper (T10): print active brain endpoint sha8
endpoint_hash_with_collision_check
;;
resolve-user-slug)
# Brain integration helper (T16 / D4 A3): resolve + persist user-slug
resolve_user_slug
;;
gbrain-refresh)
# Brain integration helper: re-detect gbrain installation state and
# persist to ~/.gstack/gbrain-detection.json. gen-skill-docs reads this
# file (when invoked with --respect-detection) to decide whether to
# render GBRAIN_CONTEXT_LOAD and GBRAIN_SAVE_RESULTS blocks in
# generated SKILL.md files.
#
# Run this after installing or uninstalling gbrain so your locally
# generated SKILL.md files match your installation state.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DETECT_BIN="$SCRIPT_DIR/gstack-gbrain-detect"
DETECTION_FILE="$STATE_DIR/gbrain-detection.json"
mkdir -p "$STATE_DIR"
if [ ! -x "$DETECT_BIN" ]; then
echo "gstack-gbrain-detect not found at $DETECT_BIN" >&2
exit 1
fi
if ! "$DETECT_BIN" > "$DETECTION_FILE.tmp" 2>/dev/null; then
printf '{"gbrain_on_path":false,"gbrain_local_status":"no-cli"}\n' > "$DETECTION_FILE.tmp"
fi
mv "$DETECTION_FILE.tmp" "$DETECTION_FILE"
# Summarize for the user. Use python (already required elsewhere) to
# parse the JSON portably; fall back to grep if python is unavailable.
PYTHON_CMD=$(command -v python3 || command -v python || true)
if [ -n "$PYTHON_CMD" ]; then
STATUS=$("$PYTHON_CMD" -c "import json,sys; d=json.load(open('$DETECTION_FILE')); print(d.get('gbrain_local_status','unknown'))" 2>/dev/null || echo unknown)
VERSION=$("$PYTHON_CMD" -c "import json,sys; d=json.load(open('$DETECTION_FILE')); print(d.get('gbrain_version') or 'unknown')" 2>/dev/null || echo unknown)
else
STATUS=$(grep -o '"gbrain_local_status":[[:space:]]*"[^"]*"' "$DETECTION_FILE" | sed 's/.*"\([^"]*\)"$/\1/')
VERSION=$(grep -o '"gbrain_version":[[:space:]]*"[^"]*"' "$DETECTION_FILE" | sed 's/.*"\([^"]*\)"$/\1/')
[ -z "$STATUS" ] && STATUS=unknown
[ -z "$VERSION" ] && VERSION=unknown
fi
case "$STATUS" in
ok)
echo "Detected gbrain v$VERSION → brain-aware blocks will render in planning-skill SKILL.md files."
echo "Run 'bun run gen:skill-docs' in the gstack repo (or re-run ./setup) to regenerate now."
;;
*)
echo "gbrain not detected (local-status: $STATUS) → brain-aware blocks will be suppressed in planning-skill SKILL.md files."
echo "Install gbrain (see /setup-gbrain) and re-run 'gstack-config gbrain-refresh' once it's configured."
;;
esac
;;
*)
echo "Usage: gstack-config {get|set|list|defaults} [key] [value]"
echo "Usage: gstack-config {get|set|list|defaults|endpoint-hash|resolve-user-slug|gbrain-refresh} [key] [value]"
exit 1
;;
esac
+61 -8
View File
@@ -19,9 +19,14 @@
# - git
# - network reachability to https://github.com
#
# The pinned commit is declared here rather than resolved dynamically so
# upgrades are explicit and reviewable. Update PINNED_COMMIT when gstack
# verifies compatibility with a new gbrain release.
# gbrain installs at the latest default-branch HEAD by default — the hard pin
# was removed in #1744 (it had drifted ~23 versions behind). Pass
# --pinned-commit <sha> to install a specific commit for reproducibility. A
# minimum-version floor (MIN_GBRAIN_VERSION) hard-fails the install when the
# resulting gbrain is too old for gstack's sync integration, and a fast
# `gbrain doctor` self-test hard-fails a broken install when gbrain is already
# configured. This keeps the version gate that the pin used to provide without
# freezing users 23 releases behind.
#
# Env:
# GBRAIN_INSTALL_DIR — override default install path (~/gbrain)
@@ -33,8 +38,14 @@
set -euo pipefail
# --- defaults ---
PINNED_COMMIT="08b3698e90532b7b66c445e6b1d8cdfe71822802" # gbrain v0.18.2
PINNED_TAG="v0.18.2"
# No version pin by default — install the latest default-branch HEAD (#1744).
# --pinned-commit <sha> overrides for reproducibility.
PINNED_COMMIT=""
PINNED_TAG=""
# Minimum gbrain version gstack's integration is known to work with. The
# `sources list --json` wrapped-object shape + federated sources landed by 0.20;
# older predates the surface gstack drives. Hard-fail below this floor (#1744).
MIN_GBRAIN_VERSION="0.20.0"
GBRAIN_REPO_URL="https://github.com/garrytan/gbrain.git"
DEFAULT_INSTALL_DIR="${GBRAIN_INSTALL_DIR:-$HOME/gbrain}"
INSTALL_DIR="$DEFAULT_INSTALL_DIR"
@@ -113,7 +124,7 @@ elif [ -n "$DETECTED_CLONE" ]; then
else
# Fresh clone path.
if $DRY_RUN; then
log "DRY RUN: would clone $GBRAIN_REPO_URL @ $PINNED_COMMIT → $INSTALL_DIR"
log "DRY RUN: would clone $GBRAIN_REPO_URL ${PINNED_COMMIT:+@ $PINNED_COMMIT }→ $INSTALL_DIR (latest HEAD unless --pinned-commit)"
exit 0
fi
if [ -d "$INSTALL_DIR" ]; then
@@ -121,8 +132,12 @@ else
fi
log "cloning $GBRAIN_REPO_URL → $INSTALL_DIR"
git clone --quiet "$GBRAIN_REPO_URL" "$INSTALL_DIR"
( cd "$INSTALL_DIR" && git checkout --quiet "$PINNED_COMMIT" )
log "pinned to $PINNED_COMMIT${PINNED_TAG:+ ($PINNED_TAG)}"
if [ -n "$PINNED_COMMIT" ]; then
( cd "$INSTALL_DIR" && git checkout --quiet "$PINNED_COMMIT" )
log "checked out pinned commit $PINNED_COMMIT${PINNED_TAG:+ ($PINNED_TAG)}"
else
log "installed latest gbrain (default-branch HEAD)"
fi
fi
if $DRY_RUN; then
@@ -195,6 +210,44 @@ fi
log "installed gbrain $actual_version from $INSTALL_DIR"
# --- minimum-version floor (#1744) ---
# Unpinning means new installs track gbrain HEAD. Hard-fail if the resulting
# version is below the floor gstack's sync integration needs — same exit-3 posture
# as the PATH-shadow / version-mismatch failures above. A warning here is exactly
# how the data-loss class slipped through, so this gate fails closed.
version_lt() {
# 0 (true) when $1 < $2 by version sort; equal versions are NOT less-than.
[ "$1" = "$2" ] && return 1
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$1" ]
}
if version_lt "$actual_norm" "$MIN_GBRAIN_VERSION"; then
echo "" >&2
echo "gstack-gbrain-install: gbrain $actual_version is below the minimum gstack-tested version ($MIN_GBRAIN_VERSION)." >&2
echo " gstack's sync integration needs the v0.20+ source/list surface." >&2
echo " Fix: update the gbrain clone at $INSTALL_DIR to a newer release (git pull), then" >&2
echo " re-run /setup-gbrain. Or pass --pinned-commit <sha> to install a specific newer commit." >&2
echo "" >&2
exit 3
fi
# --- functional self-test when gbrain is already configured (#1744) ---
# When a brain config exists (re-install / detected clone), run a fast doctor as
# a hard gate so a broken gbrain is caught at setup, not at data-loss time.
# Pre-init installs skip this (config not written yet); the full
# `/sync-gbrain --dry-run` self-test runs from /setup-gbrain after `gbrain init`.
_GBRAIN_HOME_CHECK="${GBRAIN_HOME:-$HOME/.gbrain}"
if [ -f "$_GBRAIN_HOME_CHECK/config.json" ]; then
if ! gbrain doctor --fast >/dev/null 2>&1; then
echo "" >&2
echo "gstack-gbrain-install: gbrain $actual_version installed but 'gbrain doctor --fast' failed." >&2
echo " Refusing to leave a broken gbrain in place. Run 'gbrain doctor' to see what's wrong," >&2
echo " fix it, then re-run /setup-gbrain." >&2
echo "" >&2
exit 3
fi
log "gbrain doctor --fast passed"
fi
# v1.40.0.0 post-install validation (T6 / codex review #19): --ignore-scripts
# may skip artifacts gbrain needs at runtime, especially on Windows
# MSYS/MINGW where we DID pass --ignore-scripts. `gbrain --version` above
+94 -33
View File
@@ -37,9 +37,10 @@ import { createHash } from "crypto";
import "../lib/conductor-env-shim";
import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers";
import { ensureSourceRegistered, sourcePageCount, cycleCompleted, type CycleStatus } from "../lib/gbrain-sources";
import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleCompleted, type CycleStatus } from "../lib/gbrain-sources";
import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards";
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
import { buildGbrainEnv, spawnGbrain, execGbrainJson } from "../lib/gbrain-exec";
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -52,10 +53,12 @@ export interface CliArgs {
noMemory: boolean;
noBrainSync: boolean;
codeOnly: boolean;
/** Force the brain-global dream cycle (builds the call graph). Always runs. */
/** Force the source-scoped dream cycle (builds this source's call graph). Always runs. */
dream: boolean;
/** Opt out of the dream cycle that `--full` would otherwise auto-run. */
noDream: boolean;
/** #1734: opt-in to sync a URL-managed source whose code walk may auto-reclone. */
allowReclone: boolean;
}
interface CodeStageDetail {
@@ -63,7 +66,7 @@ interface CodeStageDetail {
source_path?: string;
page_count?: number | null;
last_imported?: string;
status?: "ok" | "skipped" | "failed";
status?: "ok" | "skipped" | "failed" | "refused-autopilot" | "refused-reclone";
}
interface StageResult {
@@ -235,11 +238,13 @@ Options:
--no-memory Skip the gstack-memory-ingest stage (transcripts + artifacts).
--no-brain-sync Skip the gstack-brain-sync git pipeline stage.
--code-only Only run the code-import stage (alias for --no-memory --no-brain-sync).
--dream Force the brain-global dream cycle that builds the call
graph (gbrain code-callers/code-callees). Runs lock-free
AFTER the sync stages. ~minutes. Default timeout 45min,
override GSTACK_SYNC_DREAM_TIMEOUT_MS.
--dream Force the source-scoped dream cycle that builds this
source's call graph (gbrain code-callers/code-callees).
Runs lock-free AFTER the sync stages. ~minutes. Default
timeout 45min, override GSTACK_SYNC_DREAM_TIMEOUT_MS.
--no-dream Opt out of the dream cycle that --full would auto-run.
--allow-reclone Permit the code walk for URL-managed sources (remote_url set)
even though gbrain may auto-reclone the working tree (#1734).
--help This text.
Stages run in order: code → memory ingest → curated git push, then (lock-free)
@@ -259,6 +264,7 @@ function parseArgs(): CliArgs {
let codeOnly = false;
let dream = false;
let noDream = false;
let allowReclone = false;
for (let i = 0; i < args.length; i++) {
const a = args[i];
@@ -270,6 +276,7 @@ function parseArgs(): CliArgs {
case "--no-code": noCode = true; break;
case "--no-memory": noMemory = true; break;
case "--no-brain-sync": noBrainSync = true; break;
case "--allow-reclone": allowReclone = true; break;
case "--code-only":
codeOnly = true;
noMemory = true;
@@ -290,7 +297,7 @@ function parseArgs(): CliArgs {
}
}
return { mode, quiet, noCode, noMemory, noBrainSync, codeOnly, dream, noDream };
return { mode, quiet, noCode, noMemory, noBrainSync, codeOnly, dream, noDream, allowReclone };
}
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -450,10 +457,7 @@ export function sourceLocalPath(sourceId: string, env?: NodeJS.ProcessEnv): stri
{ baseEnv: env },
);
if (!raw) return null;
const list: Array<{ id?: string; local_path?: string }> = Array.isArray(raw)
? (raw as Array<{ id?: string; local_path?: string }>)
: ((raw as { sources?: Array<{ id?: string; local_path?: string }> }).sources ?? []);
const found = list.find((s) => s.id === sourceId);
const found = parseSourcesList(raw).find((s) => s.id === sourceId);
return found?.local_path ?? null;
}
@@ -512,20 +516,50 @@ export function planHostnameFoldMigration(
return { kind: "pending-cleanup", oldId: legacyPathHashId };
}
export interface GuardedRemoveResult {
removed: boolean;
/** True when a guard refused the remove (autopilot active or unsafe source). */
skipped: boolean;
reason: string;
}
/**
* #1734: run `gbrain sources remove <id> --confirm-destructive` only behind the
* data-loss guards. Checked immediately before the destructive op (E8: as late
* as possible) so the autopilot window is as small as we can make it without a
* gbrain-side lease. Refuses when autopilot is active or when the source is
* user-managed and gbrain can't keep its storage. Pure side-effect helper; the
* caller decides whether a skip is fatal (it never is today — removes are
* best-effort cleanup).
*/
export function safeSourcesRemove(sourceId: string, env?: NodeJS.ProcessEnv): GuardedRemoveResult {
const ap = detectAutopilot(env);
if (ap.active) {
return {
removed: false,
skipped: true,
reason: `autopilot active (${ap.signal}); refusing destructive remove of ${sourceId}. ` +
`Stop autopilot, then re-run /sync-gbrain.`,
};
}
const decision = decideSourceRemove(sourceId, env);
if (!decision.allow) {
return { removed: false, skipped: true, reason: decision.reason };
}
const r = spawnGbrain(
["sources", "remove", sourceId, "--confirm-destructive", ...decision.extraArgs],
{ baseEnv: env },
);
return { removed: r.status === 0, skipped: false, reason: decision.reason };
}
/**
* Remove an orphaned source. Called only after new-source sync verifies pages
* exist, so the old source is provably redundant before deletion.
*
* Flag note: existing call sites used `--confirm-destructive` here and
* `--yes` in `lib/gbrain-sources.ts` — gbrain 0.35.0.0 accepts neither
* deterministically (the subcommand surface help is generic). We pass
* `--confirm-destructive` to match the existing call site convention; the
* flag-helper centralization in commit 4 (lib/gbrain-exec.ts) will resolve
* the inconsistency across the codebase.
* exist, so the old source is provably redundant before deletion. Routed through
* safeSourcesRemove for the #1734 guards.
*/
export function removeOrphanedSource(oldId: string, env?: NodeJS.ProcessEnv): boolean {
const r = spawnGbrain(["sources", "remove", oldId, "--confirm-destructive"], { baseEnv: env });
return r.status === 0;
return safeSourcesRemove(oldId, env).removed;
}
/**
@@ -756,13 +790,12 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
const legacyId = deriveLegacyCodeSourceId(root);
let legacyRemoved = false;
if (legacyId !== sourceId) {
const rm = spawnGbrain(["sources", "remove", legacyId, "--confirm-destructive"], {
timeout: 30_000,
baseEnv: gbrainEnv,
});
// Treat absent-source as success (clean state). gbrain emits "not found" on
// missing id; treat any non-zero exit without "not found" as a soft fail.
if (rm.status === 0) legacyRemoved = true;
// #1734: route through the data-loss guards (autopilot + source-safety).
const rm = safeSourcesRemove(legacyId, gbrainEnv);
if (rm.skipped && !args.quiet) {
console.error(`[sync:code] legacy-source cleanup skipped: ${rm.reason}`);
}
if (rm.removed) legacyRemoved = true;
}
// Step 0b: Hostname-fold migration (#1414).
@@ -815,6 +848,29 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
process.env.GSTACK_SYNC_CODE_TIMEOUT_MS,
"GSTACK_SYNC_CODE_TIMEOUT_MS",
);
// #1734 guards, checked immediately before the destructive walk (E8):
// - autopilot active → refuse (the race that wiped a working tree).
// - URL-managed source → the walk can auto-reclone (rm-rf); require
// --allow-reclone. Both surface a visible reason and fail the stage so the
// verdict shows ERR rather than silently skipping protection.
const apBeforeWalk = detectAutopilot(gbrainEnv);
if (apBeforeWalk.active) {
return {
name: "code", ran: true, ok: false, duration_ms: Date.now() - t0,
summary: `refused: gbrain autopilot active (${apBeforeWalk.signal}). Stop autopilot, then re-run /sync-gbrain.`,
detail: { source_id: sourceId, source_path: root, status: "refused-autopilot" },
};
}
const reclone = decideCodeSync(sourceId, gbrainEnv, args.allowReclone);
if (!reclone.allow) {
return {
name: "code", ran: true, ok: false, duration_ms: Date.now() - t0,
summary: `refused: ${reclone.reason}`,
detail: { source_id: sourceId, source_path: root, status: "refused-reclone" },
};
}
const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], {
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
timeout: codeTimeoutMs,
@@ -1056,13 +1112,17 @@ function runBrainSyncPush(args: CliArgs): StageResult {
return { name: "brain-sync", ran: false, ok: true, duration_ms: 0, summary: "skipped (gstack-brain-sync not installed)" };
}
// #1731: gstack-brain-sync is a bash shebang script; Windows can't spawn it
// without a shell, which surfaced as "brain-sync exited undefined".
spawnSync(brainSyncPath, ["--discover-new"], {
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
timeout: 60 * 1000,
shell: NEEDS_SHELL_ON_WINDOWS,
});
const result = spawnSync(brainSyncPath, ["--once"], {
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
timeout: 60 * 1000,
shell: NEEDS_SHELL_ON_WINDOWS,
});
return {
@@ -1427,9 +1487,10 @@ async function main(): Promise<void> {
const anyError = stages.some((s) => s.ran && !s.ok);
exitCode = anyError ? 1 : 0;
} finally {
// Release the sync lock BEFORE the dream cycle. Dream is brain-global and
// can run ~35 min; holding the machine-wide lock that long would freeze
// every other worktree's /sync-gbrain. Dream is guarded by its own marker.
// Release the sync lock BEFORE the dream cycle. Dream is a source-scoped
// cycle that can run several minutes; holding the machine-wide lock that
// long would freeze every other worktree's /sync-gbrain. Dream is guarded
// by its own marker.
cleanup();
}
+10 -3
View File
@@ -53,18 +53,25 @@ for path in paths:
continue
if line in seen:
continue
# Prefer ISO ts field for sort; fall back to SHA-256.
# Prefer ISO ts field for sort; fall back to SHA-256. The line
# content is the final tiebreaker so the order is total: two
# entries sharing a ts must resolve identically regardless of
# which side they arrive on. Without it, equal-ts entries fall
# back to insertion order (base, ours, theirs), and since ours
# and theirs are swapped depending on which machine runs the
# merge, the two sides produce divergent files that never
# converge.
sort_key = None
try:
obj = json.loads(line)
ts = obj.get('ts') or obj.get('timestamp')
if isinstance(ts, str):
sort_key = (0, ts)
sort_key = (0, ts, line)
except (json.JSONDecodeError, ValueError, TypeError):
pass
if sort_key is None:
h = hashlib.sha256(line.encode('utf-8')).hexdigest()
sort_key = (1, h)
sort_key = (1, h, line)
seen[line] = sort_key
except FileNotFoundError:
# Absent base / absent ours / absent theirs are all valid.
+55 -6
View File
@@ -1349,10 +1349,32 @@ function installSignalForwarder(): void {
* that kill the child on parent SIGTERM/SIGINT. Returns the same shape as
* spawnSync's result so the caller doesn't care which mode was used.
*/
/**
* #1611: the `gbrain import` is the long pole on big brains. Its timeout is
* configurable via GSTACK_INGEST_TIMEOUT_MS (default 30 min, 1min24h) so large
* memory corpora aren't SIGTERM'd mid-import. On timeout we SIGTERM the child,
* which preserves gbrain's import-checkpoint.json (see installSignalForwarder)
* so the next run resumes instead of restarting from scratch.
*/
const DEFAULT_IMPORT_TIMEOUT_MS = 30 * 60 * 1000;
export function resolveImportTimeoutMs(
raw: string | undefined = process.env.GSTACK_INGEST_TIMEOUT_MS,
): number {
if (raw === undefined || raw === "") return DEFAULT_IMPORT_TIMEOUT_MS;
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || Number.isNaN(n) || n < 60_000 || n > 86_400_000) {
console.error(
`[memory-ingest] GSTACK_INGEST_TIMEOUT_MS="${raw}" invalid (need 6000086400000ms); using ${DEFAULT_IMPORT_TIMEOUT_MS}ms`,
);
return DEFAULT_IMPORT_TIMEOUT_MS;
}
return n;
}
function runGbrainImport(
stagingDir: string,
timeoutMs: number,
): Promise<{ status: number | null; stdout: string; stderr: string }> {
): Promise<{ status: number | null; stdout: string; stderr: string; timedOut: boolean }> {
installSignalForwarder();
return new Promise((resolve) => {
// Seed DATABASE_URL from gbrain's own config so this stage works
@@ -1385,6 +1407,7 @@ function runGbrainImport(
status: timedOut ? null : status,
stdout,
stderr,
timedOut,
});
});
child.on("error", (err) => {
@@ -1394,6 +1417,7 @@ function runGbrainImport(
status: null,
stdout,
stderr: stderr + `\n[spawn-error] ${(err as Error).message}`,
timedOut,
});
});
});
@@ -1608,13 +1632,33 @@ async function ingestPass(args: CliArgs): Promise<BulkResult> {
// spawn, parent termination orphans the gbrain process (observed
// during 2026-05-10 cold-run testing — gbrain kept running 15 min
// after the orchestrator timed out).
const importResult = await runGbrainImport(stagingDir, 30 * 60 * 1000);
const importResult = await runGbrainImport(stagingDir, resolveImportTimeoutMs());
const stdout = importResult.stdout || "";
const stderr = importResult.stderr || "";
const importJson = parseImportJson(stdout);
if (importResult.status !== 0) {
// #1611: on timeout, gbrain's import-checkpoint.json is preserved (the
// SIGTERM forwarder keeps the staging dir), so the next /sync-gbrain
// resumes rather than restarting. Tell the user instead of looking failed.
if (importResult.timedOut) {
const mins = Math.round(resolveImportTimeoutMs() / 60000);
const msg =
`gbrain import timed out after ${mins}min; checkpoint preserved — re-run ` +
`/sync-gbrain to resume (raise GSTACK_INGEST_TIMEOUT_MS for big brains)`;
console.error(`[memory-ingest] ${msg}`);
return {
written: 0,
skipped_secret: prep.skippedSecret,
skipped_dedup: prep.skippedDedup,
skipped_unattributed: prep.skippedUnattributed,
failed,
duration_ms: Date.now() - t0,
partial_pages: prep.partialPages,
system_error: msg,
};
}
const tail = (stderr.trim().split("\n").pop() || "").slice(0, 300);
const msg = `gbrain import exited ${importResult.status}: ${tail}`;
console.error(`[memory-ingest] ERR: ${msg}`);
@@ -1810,7 +1854,12 @@ async function main(): Promise<void> {
if (result.system_error) process.exit(1);
}
main().catch((err) => {
console.error(`gstack-memory-ingest fatal: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
// Guard so the module is import-safe for unit tests (e.g. resolveImportTimeoutMs).
// The orchestrator runs it as `bun gstack-memory-ingest.ts ...`, where
// import.meta.main is true, so the CLI path is unaffected.
if (import.meta.main) {
main().catch((err) => {
console.error(`gstack-memory-ingest fatal: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
}
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env bun
/**
* gstack-redact — scan text for secrets/PII/legal content via the shared engine.
*
* Skill-facing CLI over lib/redact-engine.ts. Reads from stdin (default) or
* --from-file, scans, and prints findings as JSON (--json) or a human table.
*
* Exit codes (consumed by skill bash to gate dispatch/file/edit/commit):
* 0 clean (no HIGH, no MEDIUM)
* 2 MEDIUM present (no HIGH) — skill runs the per-finding AskUserQuestion
* 3 HIGH present — skill blocks
*
* WARN findings (tool-fence-degraded credentials) never change the exit code.
*
* Flags:
* --json Emit JSON {findings, counts, repoVisibility, oversize}
* --repo-visibility V public | private | unknown (default unknown=public-strict wording)
* --from-file PATH Read input from PATH instead of stdin
* --allowlist PATH Newline-delimited exact spans to suppress
* --self-email EMAIL Suppress this email (the invoking user's own)
* --repo-public-emails PATH Newline-delimited repo-public emails to suppress
* --auto-redact IDS Comma-separated finding ids to auto-redact;
* prints the redacted body to stdout + diff to stderr.
* --max-bytes N Override the fail-closed size cap (default 1 MiB).
*
* Security note: this is a GUARDRAIL, not airtight enforcement. A determined
* user can always bypass it (direct gh/git). It catches accidents.
*/
import * as fs from "fs";
import * as path from "path";
import { spawnSync } from "child_process";
import {
scan,
applyRedactions,
exitCodeFor,
type RepoVisibility,
type ScanOptions,
type Finding,
} from "../lib/redact-engine";
const MAX_STDIN_BYTES = 16 * 1024 * 1024; // hard ceiling before the engine cap
// ── pre-push hook install/uninstall (chains any existing hook) ────────────────
const MANAGED_MARKER = "# gstack-redact pre-push (managed)";
function hooksPath(): string {
const r = spawnSync("git", ["rev-parse", "--git-path", "hooks"], { encoding: "utf8" });
if (r.status !== 0) {
process.stderr.write("gstack-redact: not in a git repo\n");
process.exit(1);
}
return r.stdout.trim();
}
function installPrepushHook(): void {
const dir = hooksPath();
fs.mkdirSync(dir, { recursive: true });
const hookPath = path.join(dir, "pre-push");
const prepushBin = path.join(import.meta.dir, "gstack-redact-prepush");
// If a non-managed hook exists, preserve it as pre-push.local and chain it.
if (fs.existsSync(hookPath)) {
const existing = fs.readFileSync(hookPath, "utf8");
if (existing.includes(MANAGED_MARKER)) {
process.stdout.write("gstack-redact: pre-push hook already installed.\n");
return;
}
const localPath = path.join(dir, "pre-push.local");
fs.renameSync(hookPath, localPath);
fs.chmodSync(localPath, 0o755);
process.stdout.write("gstack-redact: preserved existing hook as pre-push.local (chained).\n");
}
// stdin is single-consume: capture it once, feed both the chained hook and ours.
const wrapper = `#!/usr/bin/env bash
${MANAGED_MARKER}
set -euo pipefail
_input="$(cat)"
_local="$(git rev-parse --git-path hooks/pre-push.local)"
if [ -x "$_local" ]; then
printf '%s' "$_input" | "$_local" "$@" || exit $?
fi
printf '%s' "$_input" | bun "${prepushBin}" "$@"
`;
fs.writeFileSync(hookPath, wrapper, { mode: 0o755 });
fs.chmodSync(hookPath, 0o755);
process.stdout.write(`gstack-redact: installed pre-push hook at ${hookPath}\n`);
}
function uninstallPrepushHook(): void {
const dir = hooksPath();
const hookPath = path.join(dir, "pre-push");
const localPath = path.join(dir, "pre-push.local");
if (!fs.existsSync(hookPath) || !fs.readFileSync(hookPath, "utf8").includes(MANAGED_MARKER)) {
process.stdout.write("gstack-redact: no managed pre-push hook to remove.\n");
return;
}
if (fs.existsSync(localPath)) {
fs.renameSync(localPath, hookPath); // restore the chained original
process.stdout.write("gstack-redact: removed managed hook, restored pre-push.local.\n");
} else {
fs.unlinkSync(hookPath);
process.stdout.write("gstack-redact: removed managed pre-push hook.\n");
}
}
function arg(name: string): string | undefined {
const i = process.argv.indexOf(name);
return i >= 0 ? process.argv[i + 1] : undefined;
}
function flag(name: string): boolean {
return process.argv.includes(name);
}
function readInput(): string {
const file = arg("--from-file");
if (file) {
const st = fs.statSync(file);
if (st.size > MAX_STDIN_BYTES) {
// Don't even read it — fail closed at the CLI boundary.
process.stderr.write(`gstack-redact: input file too large (${st.size} bytes)\n`);
process.exit(3);
}
return fs.readFileSync(file, "utf8");
}
// stdin
const chunks: Buffer[] = [];
let total = 0;
const fd = 0;
const buf = Buffer.alloc(65536);
while (true) {
let n = 0;
try {
n = fs.readSync(fd, buf, 0, buf.length, null);
} catch (e: any) {
if (e.code === "EAGAIN") continue;
if (e.code === "EOF") break;
throw e;
}
if (n === 0) break;
total += n;
if (total > MAX_STDIN_BYTES) {
process.stderr.write("gstack-redact: stdin too large\n");
process.exit(3);
}
chunks.push(Buffer.from(buf.subarray(0, n)));
}
return Buffer.concat(chunks).toString("utf8");
}
function readLines(path: string | undefined): string[] | undefined {
if (!path || !fs.existsSync(path)) return undefined;
return fs
.readFileSync(path, "utf8")
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
}
function buildOpts(): ScanOptions {
const vis = (arg("--repo-visibility") as RepoVisibility) || "unknown";
const maxBytes = arg("--max-bytes");
return {
repoVisibility: ["public", "private", "unknown"].includes(vis) ? vis : "unknown",
allowlist: readLines(arg("--allowlist")),
selfEmail: arg("--self-email"),
repoPublicEmails: readLines(arg("--repo-public-emails")),
...(maxBytes ? { maxBytes: parseInt(maxBytes, 10) } : {}),
};
}
function humanTable(findings: Finding[]): string {
if (!findings.length) return " (no findings)";
const rows = findings.map(
(f) =>
` ${f.severity.padEnd(6)} ${f.id.padEnd(24)} ${String(f.line).padStart(4)}:${String(
f.col,
).padEnd(3)} ${f.preview}`,
);
return rows.join("\n");
}
function main() {
// Subcommands (positional, not flags).
const sub = process.argv[2];
if (sub === "install-prepush-hook") return installPrepushHook();
if (sub === "uninstall-prepush-hook") return uninstallPrepushHook();
const opts = buildOpts();
const input = readInput();
// Auto-redact mode: print redacted body to stdout, diff to stderr, exit 0.
const autoIds = arg("--auto-redact");
if (autoIds) {
const { body, diff, skipped } = applyRedactions(input, autoIds.split(","), opts);
process.stdout.write(body);
if (diff) process.stderr.write(diff + "\n");
if (skipped.length) {
process.stderr.write(
`\ngstack-redact: ${skipped.length} finding(s) could not be auto-redacted (structural) — edit manually:\n` +
skipped.map((f) => ` ${f.id} @ ${f.line}:${f.col}`).join("\n") +
"\n",
);
}
process.exit(0);
}
const result = scan(input, opts);
const code = exitCodeFor(result);
if (flag("--json")) {
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
} else {
const vis = result.repoVisibility.toUpperCase();
process.stdout.write(`gstack-redact scan — repo ${vis}\n`);
if (result.oversize) {
process.stdout.write(" BLOCKED — input too large to scan safely (fail-closed)\n");
} else {
process.stdout.write(humanTable(result.findings) + "\n");
const { HIGH, MEDIUM, LOW, WARN } = result.counts;
process.stdout.write(` HIGH=${HIGH} MEDIUM=${MEDIUM} LOW=${LOW} WARN=${WARN}\n`);
}
}
process.exit(code);
}
main();
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bun
/**
* gstack-redact-prepush — git pre-push hook that scans the diff being pushed for
* HIGH-severity credentials and blocks the push on a hit.
*
* THIS IS A GUARDRAIL, NOT ENFORCEMENT. `git push --no-verify` bypasses it, as
* does `GSTACK_REDACT_PREPUSH=skip`. It catches accidental credential pushes,
* the most common real-world leak. It does NOT scan history, binary/LFS/submodule
* files, or non-added lines. History scanning is /cso's job.
*
* Git pre-push interface: refs are read from STDIN, one per line:
* <local ref> <local sha> <remote ref> <remote sha>
* We scan the ADDED lines of <remote sha>..<local sha> per ref (what's being
* pushed). Special cases:
* - remote sha all-zeroes → new branch: diff against merge-base with the
* remote's default branch (fallback: scan all commits unique to local ref).
* - local sha all-zeroes → branch delete: nothing to scan, skip.
* - force-push → remote..local still gives the net new content.
*
* Behavior:
* - HIGH finding in added lines → print + exit 1 (block), for public AND private.
* - MEDIUM → warn (non-blocking). LOW/WARN → silent.
* - GSTACK_REDACT_PREPUSH=skip → log + exit 0 (escape valve).
*
* Installed/uninstalled via `gstack-redact install-prepush-hook` (see the
* gstack-redact CLI), which chains any pre-existing hook.
*/
import { spawnSync } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { scan, type Finding } from "../lib/redact-engine";
const ZERO = /^0+$/;
// The canonical empty-tree object; diffing against it yields all content as added.
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
function git(args: string[]): string {
const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
return r.status === 0 ? (r.stdout ?? "") : "";
}
function defaultRemoteBranch(): string {
// origin/HEAD → origin/main, fall back to main/master.
const sym = git(["symbolic-ref", "refs/remotes/origin/HEAD"]).trim();
if (sym) return sym.replace("refs/remotes/", "");
for (const b of ["origin/main", "origin/master"]) {
if (git(["rev-parse", "--verify", b]).trim()) return b;
}
return "origin/main";
}
/** Return the added-line text for a ref update being pushed. */
function addedLinesFor(localSha: string, remoteSha: string): string {
let range: string;
if (ZERO.test(remoteSha)) {
// New branch: prefer what's unique to localSha vs the remote default branch.
// With no merge-base (e.g. no remote yet), diff against the empty tree so ALL
// branch content is scanned as added — fail-safe (scans more, never less).
const base = git(["merge-base", localSha, defaultRemoteBranch()]).trim();
range = base ? `${base}..${localSha}` : `${EMPTY_TREE}..${localSha}`;
} else {
// Existing branch (incl. force-push): net new content remote..local.
range = `${remoteSha}..${localSha}`;
}
// -U0: only changed lines; we keep lines starting with '+' (added), drop the
// +++ file header. Unified diff added lines start with a single '+'.
const diff = git(["diff", "--unified=0", "--no-color", range]);
const added: string[] = [];
for (const line of diff.split("\n")) {
if (line.startsWith("+") && !line.startsWith("+++")) {
added.push(line.slice(1));
}
}
return added.join("\n");
}
function logSkip(reason: string): void {
try {
const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack");
const dir = path.join(home, "security");
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(
path.join(dir, "prepush-skip.jsonl"),
JSON.stringify({ ts: new Date().toISOString(), reason }) + "\n",
);
} catch {
// best-effort; never block a push because logging failed
}
}
function main() {
if ((process.env.GSTACK_REDACT_PREPUSH || "").toLowerCase() === "skip") {
logSkip(process.env.GSTACK_REDACT_PREPUSH_REASON || "env-skip");
process.stderr.write("gstack-redact-prepush: skipped via GSTACK_REDACT_PREPUSH=skip\n");
process.exit(0);
}
const stdin = fs.readFileSync(0, "utf8");
const refs = stdin
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((l) => l.split(/\s+/));
const allHigh: Finding[] = [];
let mediumCount = 0;
for (const [, localSha, , remoteSha] of refs) {
if (!localSha || ZERO.test(localSha)) continue; // branch delete → nothing pushed
const added = addedLinesFor(localSha, remoteSha || "0");
if (!added.trim()) continue;
// Visibility doesn't change HIGH behavior; pass private so nothing is treated
// as public-strict (HIGH blocks regardless either way).
const result = scan(added, { repoVisibility: "private" });
for (const f of result.findings) {
if (f.severity === "HIGH") allHigh.push(f);
else if (f.severity === "MEDIUM") mediumCount++;
}
}
if (mediumCount > 0) {
process.stderr.write(
`gstack-redact-prepush: ${mediumCount} MEDIUM finding(s) in pushed diff (PII/internal). ` +
"Not blocking. Review before this becomes public.\n",
);
}
if (allHigh.length > 0) {
process.stderr.write(
"\n⛔ gstack-redact-prepush BLOCKED the push — credential(s) in the pushed diff:\n\n",
);
for (const f of allHigh) {
process.stderr.write(` HIGH ${f.id} ${f.preview}\n`);
}
process.stderr.write(
"\nRotate the credential (a pushed secret is compromised) and remove it from the diff.\n" +
"This is a guardrail: `git push --no-verify` or `GSTACK_REDACT_PREPUSH=skip git push` bypass it.\n",
);
process.exit(1);
}
process.exit(0);
}
main();
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env bun
// gstack-version-bump — deterministic version-state classifier + writer for /ship.
//
// Extracted from ship Step 12 prose (v2 plan T9, hybrid CLI extraction). The
// idempotency classification and the dual-write to VERSION + package.json are
// pure deterministic logic; running them as tested code removes the single
// worst /ship footgun — re-bumping an already-shipped branch — from prose the
// agent could skip or misread when the step lives in a lazy-loaded section.
//
// What STAYS agent judgment (NOT here): the bump-LEVEL decision (micro/patch vs
// minor/major, which may AskUserQuestion on feature signals) and the queue
// collision prompt. The slot pick itself is bin/gstack-next-version. This CLI
// only answers "what state am I in?" and "write this exact version".
//
// Subcommands:
// classify --base <branch> [--version-path <p>]
// Compares VERSION vs origin/<base>:VERSION vs package.json.version.
// Emits JSON: { state, baseVersion, currentVersion, pkgVersion, pkgExists }
// state ∈ FRESH | ALREADY_BUMPED | DRIFT_STALE_PKG | DRIFT_UNEXPECTED
// Exit 0 on a decidable state (incl. DRIFT_UNEXPECTED — it's a real state
// the caller must handle), exit 2 on bad args / unresolvable base.
//
// write --version <X.Y.Z.W> [--version-path <p>]
// Validates the 4-digit pattern, writes VERSION + package.json.version.
// Use for the FRESH bump (or an approved queue rebump). Exit 3 on a
// half-write (VERSION written, package.json failed) so the caller knows
// drift exists; the next classify() will report DRIFT_STALE_PKG.
//
// repair [--version-path <p>]
// DRIFT_STALE_PKG path: sync package.json.version to the current VERSION
// file. No bump. Validates the VERSION pattern first.
//
// Contract: classify NEVER writes. write/repair mutate VERSION + package.json
// only. No git mutation, no network. Mirrors gstack-next-version's reader/writer
// split so /ship composes them.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/;
const DEFAULT = "0.0.0.0";
type State = "FRESH" | "ALREADY_BUMPED" | "DRIFT_STALE_PKG" | "DRIFT_UNEXPECTED";
function fail(msg: string, code = 2): never {
process.stderr.write(`gstack-version-bump: ${msg}\n`);
process.exit(code);
}
function argVal(args: string[], flag: string): string | undefined {
const i = args.indexOf(flag);
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
}
/** Resolve the VERSION file path: --version-path, else .gstack/version-path, else "VERSION". */
function resolveVersionPath(cwd: string, explicit?: string): string {
if (explicit) return join(cwd, explicit);
const pin = join(cwd, ".gstack", "version-path");
if (existsSync(pin)) {
const p = readFileSync(pin, "utf-8").trim();
if (p) return join(cwd, p);
}
return join(cwd, "VERSION");
}
function readVersionFile(p: string): string {
try {
const v = readFileSync(p, "utf-8").replace(/[\r\n\s]/g, "");
return v || DEFAULT;
} catch {
return DEFAULT;
}
}
/** package.json version + existence, parsed without spawning node. */
function readPkgVersion(cwd: string): { exists: boolean; version: string } {
const pkgPath = join(cwd, "package.json");
if (!existsSync(pkgPath)) return { exists: false, version: "" };
let raw: string;
try {
raw = readFileSync(pkgPath, "utf-8");
} catch {
return { exists: true, version: "" };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
fail("package.json is not valid JSON. Fix the file before re-running /ship.", 2);
}
const version = (parsed as { version?: unknown })?.version;
return { exists: true, version: typeof version === "string" ? version : "" };
}
function writePkgVersion(cwd: string, version: string): void {
const pkgPath = join(cwd, "package.json");
const raw = readFileSync(pkgPath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
parsed.version = version;
writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + "\n");
}
function baseVersion(cwd: string, base: string, versionRel: string): string {
// Verify the base ref resolves, mirroring the Step 12 guard.
try {
execFileSync("git", ["rev-parse", "--verify", `origin/${base}`], { cwd, stdio: "ignore" });
} catch {
fail(`Unable to resolve origin/${base}. Run 'git fetch origin' or verify the base branch exists.`, 2);
}
try {
const out = execFileSync("git", ["show", `origin/${base}:${versionRel}`], { cwd }).toString();
const v = out.replace(/[\r\n\s]/g, "");
return v || DEFAULT;
} catch {
// VERSION absent on base (new repo / new file) → treat as 0.0.0.0.
return DEFAULT;
}
}
function classifyState(current: string, base: string, pkgExists: boolean, pkgVersion: string): State {
if (current === base) {
// VERSION unchanged vs base. A diverging package.json means someone hand-edited
// package.json bypassing /ship — unsafe to guess which is authoritative.
if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_UNEXPECTED";
return "FRESH";
}
// VERSION already moved past base.
if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_STALE_PKG";
return "ALREADY_BUMPED";
}
function cmdClassify(args: string[], cwd: string): void {
const base = argVal(args, "--base");
if (!base) fail("classify requires --base <branch>", 2);
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
const versionRel = argVal(args, "--version-path") ?? "VERSION";
const current = readVersionFile(versionPath);
const baseV = baseVersion(cwd, base!, versionRel);
const pkg = readPkgVersion(cwd);
const state = classifyState(current, baseV, pkg.exists, pkg.version);
process.stdout.write(
JSON.stringify({
state,
baseVersion: baseV,
currentVersion: current,
pkgVersion: pkg.version || null,
pkgExists: pkg.exists,
}) + "\n",
);
// DRIFT_UNEXPECTED is a real, decidable state — the caller stops on it, but the
// classification itself succeeded, so exit 0. (Bad args / unresolvable base are
// the only exit-2 cases.)
}
function cmdWrite(args: string[], cwd: string): void {
const version = argVal(args, "--version");
if (!version) fail("write requires --version <X.Y.Z.W>", 2);
if (!VERSION_RE.test(version!)) {
fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH.MICRO. Aborting.`, 2);
}
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
writeFileSync(versionPath, version + "\n");
if (existsSync(join(cwd, "package.json"))) {
try {
writePkgVersion(cwd, version!);
} catch {
fail(
"failed to update package.json. VERSION was written but package.json is now stale. " +
"Re-run — classify will report DRIFT_STALE_PKG and repair will sync it.",
3,
);
}
}
process.stdout.write(JSON.stringify({ wrote: version, packageJson: existsSync(join(cwd, "package.json")) }) + "\n");
}
function cmdRepair(args: string[], cwd: string): void {
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
const current = readVersionFile(versionPath);
if (!VERSION_RE.test(current)) {
fail(
`VERSION file contents (${current}) do not match MAJOR.MINOR.PATCH.MICRO. ` +
"Refusing to propagate invalid semver into package.json. Fix VERSION, then re-run /ship.",
2,
);
}
if (!existsSync(join(cwd, "package.json"))) {
fail("repair: no package.json to sync.", 2);
}
try {
writePkgVersion(cwd, current);
} catch {
fail("drift repair failed — could not update package.json.", 3);
}
process.stdout.write(JSON.stringify({ repaired: current }) + "\n");
}
// Exported for unit tests (pure logic, no I/O).
export { classifyState, VERSION_RE, type State };
if (import.meta.main) {
const [sub, ...rest] = process.argv.slice(2);
const cwd = process.cwd();
switch (sub) {
case "classify": cmdClassify(rest, cwd); break;
case "write": cmdWrite(rest, cwd); break;
case "repair": cmdRepair(rest, cwd); break;
default:
fail("usage: gstack-version-bump <classify|write|repair> [flags]", 2);
}
}