Merge origin/main (v1.83.0.0, Memorable recall bridge) into tehran-v1; re-bump to v1.84.0.0

Conflicts resolved by keeping both sides: gstack-config enumerates
design_detector, design_detector_install_prompted, and memorable_recall; the
egress wiring test carries both new fail-closed sinks (design-detect-engine-
download, memorable-recall) and both new module sinks; PROJECT_STRUCTURE's
bin/ line names the design tools and gstack-memorable. This branch's
CHANGELOG entry moves to 1.84.0.0 (dated today) above main's 1.83.0.0;
VERSION, package.json, and the agents digest were written by
gstack-version-bump. Main touched no template or resolver, so no render
changed (gen-skill-docs --dry-run: all FRESH).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-09 05:23:57 +00:00
co-authored by Claude Fable 5.1
35 changed files with 3745 additions and 58 deletions
+32 -7
View File
@@ -72,6 +72,13 @@ export interface WriteReceiptOptions {
sha256?: string | null;
/** the consent key+value that authorizes this send */
consent: string;
/**
* Max milliseconds to wait for the ledger lock (default LEDGER_LOCK_BUDGET_MS).
* Callers on an interactive hot path with their own deadline (a Claude Code
* hook under a 5 s kill) pass what they can afford; the lock is always
* tried at least once.
*/
lockBudgetMs?: number;
}
export interface WriteOutcomeOptions {
@@ -80,8 +87,13 @@ export interface WriteOutcomeOptions {
/** receipt id returned by writeReceipt */
receipt: string;
status?: string | number;
/** see WriteReceiptOptions.lockBudgetMs */
lockBudgetMs?: number;
}
/** Default spin budget for the ledger lock. Egress events are rare (minutes apart). */
export const LEDGER_LOCK_BUDGET_MS = 2500;
export interface LedgerLine {
lineNo: number;
raw: string;
@@ -139,8 +151,10 @@ function requireString(value: unknown, name: string): string {
}
/**
* mkdir spin lock, ~2.5s budget. Egress events are rare (minutes apart); the
* lock only protects the read-last-line → append window.
* mkdir spin lock; the budget defaults to LEDGER_LOCK_BUDGET_MS (2.5 s) and
* callers on their own deadline pass less. Egress events are usually rare
* (minutes apart; the memorable hook is the per-prompt exception); the lock
* only protects the read-last-line → append window.
*
* Stale-lock reclaim: a crashed writer strands the lock dir. Once the spin
* budget is exhausted, a lock dir whose mtime is >10s old is stale by
@@ -148,9 +162,9 @@ function requireString(value: unknown, name: string): string {
* retries instead of failing. The rmdir/stat races with a concurrent
* reclaimer or the owner's own cleanup are harmless — losers just loop.
*/
function withLedgerLock<T>(ledger: string, callback: () => T): T {
function withLedgerLock<T>(ledger: string, callback: () => T, budgetMs: number = LEDGER_LOCK_BUDGET_MS): T {
const lock = `${ledger}.lock`;
const deadline = Date.now() + 2500;
const deadline = Date.now() + Math.max(0, budgetMs);
for (;;) {
try {
fs.mkdirSync(lock);
@@ -240,10 +254,19 @@ export function ledgerSizeWarning(ledger: string, size: number): string {
);
}
function lockBudget(value: number | undefined): number {
if (value === undefined) return LEDGER_LOCK_BUDGET_MS;
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
throw receiptError('Egress receipt lockBudgetMs must be a non-negative finite number');
}
return value;
}
function appendChained(
homeOrNull: string | null,
record: Record<string, unknown>,
env?: Env,
budgetMs: number = LEDGER_LOCK_BUDGET_MS,
): { id: string; path: string } {
const home = homeOrNull ?? resolveEgressHome(env);
const ledger = egressLedgerPath(home);
@@ -256,7 +279,7 @@ function appendChained(
fs.appendFileSync(ledger, `${line}\n`, { mode: 0o600 });
if (!existed) fs.chmodSync(ledger, 0o600); // umask must not weaken the ledger
return { id: sha256Hex(line), path: ledger };
});
}, budgetMs);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === EGRESS_RECEIPT_FAILED) throw error;
throw receiptError(
@@ -282,6 +305,7 @@ export function writeReceipt(opts: WriteReceiptOptions): { id: string; path: str
if (!Number.isSafeInteger(bytes) || bytes < 0) throw receiptError('Egress receipt bytes must be a non-negative integer');
const sha256 = opts.sha256 ?? null;
if (sha256 !== null && !SHA256_HEX.test(String(sha256))) throw receiptError('Egress receipt sha256 must be 64 lowercase hex chars or null');
const budgetMs = lockBudget(opts.lockBudgetMs);
const home = opts.home ?? resolveEgressHome(opts.env);
warnLedgerSizeOnce(egressLedgerPath(home));
return appendChained(home, {
@@ -293,7 +317,7 @@ export function writeReceipt(opts: WriteReceiptOptions): { id: string; path: str
bytes,
sha256,
consent,
}, opts.env);
}, opts.env, budgetMs);
}
/**
@@ -303,12 +327,13 @@ export function writeReceipt(opts: WriteReceiptOptions): { id: string; path: str
*/
export function writeOutcome(opts: WriteOutcomeOptions): { id: string; path: string } {
const receipt = requireString(opts.receipt, 'receipt id');
const budgetMs = lockBudget(opts.lockBudgetMs);
return appendChained(opts.home ?? null, {
ts: new Date().toISOString(),
type: 'outcome',
receipt,
status: String(opts.status ?? 'unknown'),
}, opts.env);
}, opts.env, budgetMs);
}
/** Raw parsed lines: [{lineNo, raw, record|null}]. Missing ledger → []. */
+10 -2
View File
@@ -57,8 +57,16 @@ const POLICY_SCRIPT = join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-pol
* Fast paths (no subprocess): no store on disk → `none`; no remote URL →
* `none` (policy is keyed by origin remote, so nothing can be set for the
* repo). Everything else shells to the script, which owns normalization.
*
* `timeoutMs` bounds that spawn (default 10 s). A caller on its own deadline
* (a Claude Code hook) passes what it can afford; a timeout reads as
* `unreadable`, and polarity stays the caller's.
*/
export function repoPolicyTier(url: string | null, env: NodeJS.ProcessEnv = process.env): RepoPolicyResult {
export function repoPolicyTier(
url: string | null,
env: NodeJS.ProcessEnv = process.env,
timeoutMs: number = 10_000,
): RepoPolicyResult {
if (!hasRepoPolicyStore(env)) return { tier: "none" };
if (!url) return { tier: "none" };
// The script is `#!/usr/bin/env bash`; win32 can't exec a shebang file, so
@@ -67,7 +75,7 @@ export function repoPolicyTier(url: string | null, env: NodeJS.ProcessEnv = proc
process.platform === "win32" ? ["bash", [POLICY_SCRIPT, "get", url]] : [POLICY_SCRIPT, ["get", url]];
const res = spawnSync(cmd, args, {
encoding: "utf-8",
timeout: 10_000,
timeout: Math.max(1, timeoutMs),
// Explicit env: Bun's spawnSync default env snapshot misses runtime
// process.env mutations (e.g. tests redirecting GSTACK_HOME).
env: { ...env } as NodeJS.ProcessEnv,
+24 -12
View File
@@ -163,18 +163,28 @@ export function normalizeWithMap(input: string): {
// ── Offset → line/col on the ORIGINAL text ────────────────────────────────────
function lineColAt(original: string, offset: number): { line: number; col: number } {
let line = 1;
let col = 1;
for (let i = 0; i < offset && i < original.length; i++) {
if (original[i] === "\n") {
line += 1;
col = 1;
} else {
col += 1;
}
/** Start offset of every line, built once per scan and only when a finding needs it. */
function lineStarts(original: string): number[] {
const starts = [0];
for (let i = 0; i < original.length; i++) if (original[i] === "\n") starts.push(i + 1);
return starts;
}
/**
* Binary search over lineStarts: O(log lines) per finding. The previous walk
* from offset 0 per finding made a match-dense input (a pasted log full of
* emails and IPs) cost O(findings x bytes) — seconds for a few hundred KiB.
*/
function lineColAt(starts: number[], original: string, offset: number): { line: number; col: number } {
const at = Math.min(Math.max(0, offset), original.length);
let lo = 0;
let hi = starts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (starts[mid] <= at) lo = mid;
else hi = mid - 1;
}
return { line, col };
return { line: lo + 1, col: at - starts[lo] + 1 };
}
// ── Safe preview masking ──────────────────────────────────────────────────────
@@ -318,6 +328,7 @@ function emailAllowed(
export function scan(input: string, opts: ScanOptions = {}): ScanResult {
const repoVisibility: RepoVisibility = opts.repoVisibility ?? "unknown";
let starts: number[] | null = null; // line index, built on the first finding
// #1824: ?? only catches null/undefined, not NaN or <= 0. A bad value
// (NaN from a malformed --max-bytes, or a negative) would make `byteLen >
// maxBytes` always false and silently disable the fail-closed oversize guard.
@@ -395,7 +406,8 @@ export function scan(input: string, opts: ScanOptions = {}): ScanResult {
if (seen.has(key)) continue;
seen.add(key);
const { line, col } = lineColAt(input, origOffset);
starts ??= lineStarts(input);
const { line, col } = lineColAt(starts, input, origOffset);
// Tool-fence degrade: only credential-category, only obvious doc examples.
let severity: Severity = pat.tier;