diff --git a/bin/gstack-context-bill b/bin/gstack-context-bill index 50b085700..a0cac12a7 100755 --- a/bin/gstack-context-bill +++ b/bin/gstack-context-bill @@ -2,6 +2,7 @@ // gstack-context-bill — token bill-of-materials for an installed skills tree. // All behavior lives in lib/context-bill.ts; this is the CLI shim. +import '../lib/conductor-env-shim'; // --exact needs GSTACK_ANTHROPIC_API_KEY promotion inside Conductor import { contextBillMain } from '../lib/context-bill'; process.exit(await contextBillMain(process.argv.slice(2))); diff --git a/bin/gstack-distill-free-text b/bin/gstack-distill-free-text index 4f0688dcb..fe75c45a4 100755 --- a/bin/gstack-distill-free-text +++ b/bin/gstack-distill-free-text @@ -197,8 +197,13 @@ RESULT=$(EVENTS_JSON="$EVENTS_JSON" DISTILL_PROMPT="$DISTILL_PROMPT" \ const INPUT_PER_TOKEN = 1e-6; const OUTPUT_PER_TOKEN = 5e-6; + // Host-neutral model resolution (mirrors lib/eval-model.ts — this inline + // bun -e script cannot import repo-relative libs from an arbitrary cwd). + const distillModel = process.env.GSTACK_EVAL_MODEL_DISTILL + || process.env.GSTACK_EVAL_MODEL + || "claude-haiku-4-5-20251001"; const resp = await client.messages.create({ - model: "claude-haiku-4-5-20251001", + model: distillModel, max_tokens: 4096, messages: [{ role: "user", content: prompt }], }); diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index 6fb553840..21786f2ed 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -1884,6 +1884,18 @@ async function ingestPass(args: CliArgs): Promise { : ""), ); } + // Silent-zero pathology detector (#2144's other half): pages were staged + // but NOTHING imported or skipped-as-unchanged. That shape hid the dead + // ingest for months — it must be loud even under --quiet, because a run + // that indexes nothing is otherwise indistinguishable from a healthy one. + const importedCount = (importJson.imported ?? 0) + (importJson.skipped ?? 0); + if (prep.prepared.length > 0 && importedCount === 0 && (importJson.errors ?? 0) === 0) { + console.error( + `[memory-ingest] WARNING: ${prep.prepared.length} page(s) staged but gbrain collected ZERO ` + + `(no imports, no unchanged-skips, no errors). This is the #2144 silent-zero shape — ` + + `check gbrain's import.collect_files log line and your gbrain version.`, + ); + } } finally { // #1802 D1: in remote-http mode `stagingDir` is the PERSISTENT transcript // dir (makePersistentTranscriptDir, under ~/.gstack/transcripts/) that diff --git a/bin/gstack-telemetry-log b/bin/gstack-telemetry-log index f94e25462..20febd32e 100755 --- a/bin/gstack-telemetry-log +++ b/bin/gstack-telemetry-log @@ -210,6 +210,13 @@ fi STEP_FIELD="null" [ -n "$FAILED_STEP" ] && STEP_FIELD="\"$(json_safe "$FAILED_STEP")\"" +# Integrity first: a non-numeric duration would splice raw text into the +# JSON line ("duration_s":%s) and corrupt the whole JSONL stream — the range +# caps below silently no-op on non-integers because both test(1) comparisons +# fail. Reject anything that isn't a plain integer. +case "$DURATION" in + ''|*[!0-9]*) DURATION="" ;; +esac # Cap unreasonable durations if [ -n "$DURATION" ] && [ "$DURATION" -gt 86400 ] 2>/dev/null; then DURATION="" # null if > 24h diff --git a/bin/gstack-telemetry-sync b/bin/gstack-telemetry-sync index 172fed623..1a4ac02b8 100755 --- a/bin/gstack-telemetry-sync +++ b/bin/gstack-telemetry-sync @@ -81,15 +81,27 @@ while IFS= read -r LINE; do [ -z "$LINE" ] && continue echo "$LINE" | grep -q '^{' || continue - # Strip local-only fields (keep v, ts, sessions as-is for edge function) - CLEAN="$(echo "$LINE" | sed \ - -e 's/,"_repo_slug":"[^"]*"//g' \ - -e 's/,"_branch":"[^"]*"//g' \ - -e 's/,"repo":"[^"]*"//g')" - - # If anonymous tier, strip installation_id - if [ "$TIER" = "anonymous" ]; then - CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')" + # Strip local-only fields (keep v, ts, sessions as-is for edge function). + # jq del() is structural — a value containing an escaped quote (repo names, + # branch names) can't smuggle the field past a regex or corrupt the strip. + # The sed path stays only as a jq-less fallback. + if command -v jq >/dev/null 2>&1; then + if [ "$TIER" = "anonymous" ]; then + CLEAN="$(printf '%s' "$LINE" | jq -c 'del(._repo_slug, ._branch, .repo, .installation_id)' 2>/dev/null)" || CLEAN="" + else + CLEAN="$(printf '%s' "$LINE" | jq -c 'del(._repo_slug, ._branch, .repo)' 2>/dev/null)" || CLEAN="" + fi + # A line jq can't parse is malformed telemetry — drop it rather than + # forwarding bytes the strip never touched. + [ -z "$CLEAN" ] && continue + else + CLEAN="$(echo "$LINE" | sed \ + -e 's/,"_repo_slug":"[^"]*"//g' \ + -e 's/,"_branch":"[^"]*"//g' \ + -e 's/,"repo":"[^"]*"//g')" + if [ "$TIER" = "anonymous" ]; then + CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')" + fi fi if [ "$FIRST" = "true" ]; then diff --git a/browse/src/telemetry.ts b/browse/src/telemetry.ts index 8f2604e4e..b728c5f01 100644 --- a/browse/src/telemetry.ts +++ b/browse/src/telemetry.ts @@ -50,8 +50,23 @@ function isDisabled(): boolean { telemetryDisabled = true; return true; } + // Persistent tier: gstack-config set telemetry off must hold even when the + // daemon is spawned outside a skill preamble (direct $B use, embedders) and + // the env hint was never set (fork port wave 2 polish). + try { + const fs = require('fs') as typeof import('fs'); + const path = require('path') as typeof import('path'); + const os = require('os') as typeof import('os'); + const home = process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'); + const yaml = fs.readFileSync(path.join(home, 'config.yaml'), 'utf-8'); + if (/^\s*telemetry\s*:\s*['"]?off['"]?\s*(?:#.*)?$/m.test(yaml)) { + telemetryDisabled = true; + return true; + } + } catch { /* no config — fall through to default */ } // Conservative default: telemetry ON unless explicitly off. Users opt out via - // gstack-config set telemetry off (preamble reads this; we trust the env hint). + // gstack-config set telemetry off (env hint from the preamble OR the + // persistent tier read above). telemetryDisabled = false; return false; } diff --git a/lib/context-bill.ts b/lib/context-bill.ts index bd191004a..b264e72c9 100644 --- a/lib/context-bill.ts +++ b/lib/context-bill.ts @@ -222,6 +222,20 @@ function totalMd(dir: string, tokensOf: TokensOf): { bytes: number; tokens: numb let bytes = 0; let tokens = 0; for (const p of walkMd(dir)) { + // A skill dir that CONTAINS other skill dirs (the gstack root skill wraps + // the whole tree) must not swallow its children's files: each nested + // skill reports its own totalMd, and the grand total sums per-skill + // figures — counting them here again double-counted every nested skill + // in the TOTAL line (v1.63 deferred polish, fixed in fork port wave 2). + const rel = path.relative(dir, p); + const topSeg = rel.split(path.sep)[0]; + if ( + topSeg && + topSeg !== rel && // p is inside a subdirectory + fs.existsSync(path.join(dir, topSeg, "SKILL.md")) + ) { + continue; + } const b = bytesOf(p) ?? 0; bytes += b; tokens += tokensOf(p, b); diff --git a/lib/eval-model.ts b/lib/eval-model.ts new file mode 100644 index 000000000..7aafab3eb --- /dev/null +++ b/lib/eval-model.ts @@ -0,0 +1,40 @@ +/** + * Host-neutral eval/harness model resolution (fork port wave 2, G cluster). + * + * Model IDs were hardcoded at six call sites across the eval helpers and the + * distill bin, so an environment that pins a different model (CI cost + * control, a model migration, an air-gapped proxy alias) had to patch source. + * One resolution point, env-overridable: + * + * GSTACK_EVAL_MODEL_ (e.g. GSTACK_EVAL_MODEL_WARMUP) — per-kind + * GSTACK_EVAL_MODEL — global + * explicit argument — caller wins + * per-kind default — last resort + * + * Kinds and their defaults: + * capture — AskUserQuestion SDK capture runs (quality matters): opus + * warmup — PTY warm-up ping (cheapest thing that answers): haiku + * distill — free-text distillation (cheap, structured): haiku (pinned) + */ + +const DEFAULTS: Record = { + capture: "claude-opus-4-7", + warmup: "claude-haiku-4-5", + distill: "claude-haiku-4-5-20251001", +}; + +export type EvalModelKind = keyof typeof DEFAULTS & string; + +export function resolveEvalModel( + kind: EvalModelKind, + explicit?: string | null, + env: NodeJS.ProcessEnv = process.env, +): string { + if (explicit) return explicit; + const perKind = env[`GSTACK_EVAL_MODEL_${kind.toUpperCase()}`]; + if (perKind) return perKind; + if (env.GSTACK_EVAL_MODEL) return env.GSTACK_EVAL_MODEL; + const fallback = DEFAULTS[kind]; + if (!fallback) throw new Error(`resolveEvalModel: unknown kind "${kind}"`); + return fallback; +} diff --git a/scripts/resolvers/review.ts b/scripts/resolvers/review.ts index 142703ce0..18db46c9f 100644 --- a/scripts/resolvers/review.ts +++ b/scripts/resolvers/review.ts @@ -1147,16 +1147,23 @@ Using the plan file already discovered in Step 8, look for a verification sectio ### 2. Check for running dev server -Before invoking browse-based verification, check if a dev server is reachable: +Before invoking browse-based verification, find the dev-server URL the way the +project declares it — never trust a hardcoded port list alone: + +1. **CLAUDE.md first:** look for a documented dev URL or dev command (a + \`## Development\`/\`## Testing\` section naming a port or URL). Use it. +2. **The plan file:** if the plan's verification section names a URL, use it. +3. **Fallback probe** (common ports, only when 1-2 found nothing): \`\`\`bash -curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 2>/dev/null || \\ -curl -s -o /dev/null -w '%{http_code}' http://localhost:8080 2>/dev/null || \\ -curl -s -o /dev/null -w '%{http_code}' http://localhost:5173 2>/dev/null || \\ -curl -s -o /dev/null -w '%{http_code}' http://localhost:4000 2>/dev/null || echo "NO_SERVER" +for _p in 3000 8080 5173 4000 4321 8000; do + _code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$_p" 2>/dev/null) + [ -n "$_code" ] && [ "$_code" != "000" ] && { echo "DEV_SERVER: http://localhost:$_p ($_code)"; break; } +done +[ -z "\${_code:-}" ] || [ "\${_code:-000}" = "000" ] && echo "NO_SERVER" \`\`\` -**If NO_SERVER:** Skip with "No dev server detected — skipping plan verification. Run /qa separately after deploying." +**If NO_SERVER:** Skip with "No dev server detected (checked CLAUDE.md, the plan, and common ports) — skipping plan verification. Run /qa separately after deploying, or document the dev URL in CLAUDE.md so this step finds it next time." ### 3. Invoke /qa-only inline diff --git a/setup b/setup index 275236cd3..44f1261f7 100755 --- a/setup +++ b/setup @@ -249,20 +249,45 @@ if [ "$INSTALL_CODEX" -eq 1 ]; then migrate_direct_codex_install "$SOURCE_GSTACK_DIR" "$CODEX_GSTACK" fi +# Deadline-bounded wait for a background probe. macOS ships no GNU timeout; +# poll the PID and SIGKILL past the deadline. Returns the probe's exit code, +# or 124 on timeout. +_wait_with_deadline() { + local pid="$1" deadline_s="$2" waited=0 + while kill -0 "$pid" 2>/dev/null; do + if [ "$waited" -ge "$deadline_s" ]; then + kill -9 "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + return 124 + fi + sleep 1 + waited=$((waited + 1)) + done + wait "$pid" +} + ensure_playwright_browser() { - if [ "$IS_WINDOWS" -eq 1 ]; then - # On Windows, Bun can't launch Chromium due to broken pipe handling - # (oven-sh/bun#4253). Use Node.js to verify Chromium works instead. - ( - cd "$SOURCE_GSTACK_DIR" - node -e "const { chromium } = require('playwright'); (async () => { const b = await chromium.launch(); await b.close(); })()" 2>/dev/null - ) + # #2136: fresh installs hung forever at this probe (macOS arm64) and + # re-runs stacked stuck process trees, so skills never got linked. Two + # fixes: prefer Node for the launch probe everywhere it exists (the + # bun --eval launch is the same pipe-bug family already worked around on + # Windows), and bound the probe with a 90s deadline — a wedged probe now + # reports failure (which routes to the install path) instead of hanging + # setup. + local probe_cmd + if command -v node >/dev/null 2>&1; then + probe_cmd='node -e "const { chromium } = require((process.cwd()) + \"/node_modules/playwright\"); (async () => { const b = await chromium.launch(); await b.close(); })().then(() => process.exit(0), () => process.exit(1))"' + elif [ "$IS_WINDOWS" -eq 1 ]; then + echo "gstack setup failed: Node.js is required on Windows" >&2 + return 1 else - ( - cd "$SOURCE_GSTACK_DIR" - bun --eval 'import { chromium } from "playwright"; const browser = await chromium.launch(); await browser.close();' - ) >/dev/null 2>&1 + probe_cmd="bun --eval 'import { chromium } from \"playwright\"; const browser = await chromium.launch(); await browser.close();'" fi + ( + cd "$SOURCE_GSTACK_DIR" + eval "$probe_cmd" + ) >/dev/null 2>&1 & + _wait_with_deadline $! 90 } # Ensure a color-emoji font is installed (Linux only). @@ -478,10 +503,21 @@ fi # 2. Ensure Playwright's Chromium is available if ! ensure_playwright_browser; then echo "Installing Playwright Chromium..." - ( - cd "$SOURCE_GSTACK_DIR" - bunx playwright install chromium - ) + _PW_LOCK="${TMPDIR:-/tmp}/gstack-playwright-install.lock" + if mkdir "$_PW_LOCK" 2>/dev/null; then + trap 'rmdir "$_PW_LOCK" 2>/dev/null || true' EXIT + ( + cd "$SOURCE_GSTACK_DIR" + bunx playwright install chromium + ) + rmdir "$_PW_LOCK" 2>/dev/null || true + trap - EXIT + else + echo " another gstack setup is already installing Chromium (lock: $_PW_LOCK)." >&2 + echo " Wait for it to finish, then re-run ./setup. If no other setup is running," >&2 + echo " remove the stale lock: rmdir \"$_PW_LOCK\"" >&2 + exit 1 + fi if [ "$IS_WINDOWS" -eq 1 ]; then # On Windows, Node.js launches Chromium (not Bun — see oven-sh/bun#4253). diff --git a/test/eval-model.test.ts b/test/eval-model.test.ts new file mode 100644 index 000000000..a4dfab878 --- /dev/null +++ b/test/eval-model.test.ts @@ -0,0 +1,23 @@ +/** OV11: the host-neutral eval-model resolver's contract. */ +import { describe, test, expect } from "bun:test"; +import { resolveEvalModel } from "../lib/eval-model"; + +describe("resolveEvalModel", () => { + test("explicit argument wins over everything", () => { + expect(resolveEvalModel("capture", "my-model", { GSTACK_EVAL_MODEL: "x" } as never)).toBe("my-model"); + }); + test("per-kind env beats the global env", () => { + expect(resolveEvalModel("warmup", null, { GSTACK_EVAL_MODEL_WARMUP: "w", GSTACK_EVAL_MODEL: "g" } as never)).toBe("w"); + }); + test("global env beats the default", () => { + expect(resolveEvalModel("distill", null, { GSTACK_EVAL_MODEL: "g" } as never)).toBe("g"); + }); + test("defaults per kind", () => { + expect(resolveEvalModel("capture", null, {} as never)).toBe("claude-opus-4-7"); + expect(resolveEvalModel("warmup", null, {} as never)).toBe("claude-haiku-4-5"); + expect(resolveEvalModel("distill", null, {} as never)).toBe("claude-haiku-4-5-20251001"); + }); + test("unknown kind throws instead of silently defaulting", () => { + expect(() => resolveEvalModel("banana" as never, null, {} as never)).toThrow(); + }); +}); diff --git a/test/helpers/auq-sdk-capture.ts b/test/helpers/auq-sdk-capture.ts index a95a4b05b..9bb08acf7 100644 --- a/test/helpers/auq-sdk-capture.ts +++ b/test/helpers/auq-sdk-capture.ts @@ -11,6 +11,7 @@ * zero rendering loss. The TTY rendering layer is identical for fat and slim * skills, so it is not where token-reduction degradation can hide. */ +import { resolveEvalModel } from '../../lib/eval-model'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -191,7 +192,7 @@ This is a capture test, not an interactive session. Skip any system-audit / envi timeout: 240_000, testName: opts.testName, runId: opts.runId, - model: opts.model ?? 'claude-opus-4-7', + model: resolveEvalModel('capture', opts.model), }); try { @@ -253,7 +254,7 @@ Rules for this run: timeout: opts.timeout ?? 300_000, testName: opts.testName, runId: opts.runId, - model: opts.model ?? 'claude-opus-4-7', + model: resolveEvalModel('capture', opts.model), }); const readSections = new Set(); @@ -334,7 +335,7 @@ Write the verbatim text of that AskUserQuestion (the full decision brief: title, timeout: 240_000, testName: opts.testName, runId: opts.runId, - model: opts.model ?? 'claude-opus-4-7', + model: resolveEvalModel('capture', opts.model), }); try { diff --git a/test/helpers/claude-pty-runner.ts b/test/helpers/claude-pty-runner.ts index 669dacb48..e0600430c 100644 --- a/test/helpers/claude-pty-runner.ts +++ b/test/helpers/claude-pty-runner.ts @@ -21,6 +21,7 @@ * tests don't need it). */ +import { resolveEvalModel } from '../../lib/eval-model'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -456,7 +457,7 @@ ${tail} try { const result = nodeSpawnSync( 'claude', - ['-p', '--model', 'claude-haiku-4-5', '--max-turns', '1'], + ['-p', '--model', resolveEvalModel('warmup'), '--max-turns', '1'], { input: prompt, stdio: ['pipe', 'pipe', 'pipe'],