mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-23 14:32:33 +02:00
* fix(ci): skill-docs freshness gate covers all 10 hosts and can actually fail The Codex/Factory gates ran 'git diff --exit-code -- .agents/' / '-- .factory/', but both paths are gitignored (.gitignore:16-17) — git diff on ignored untracked paths is always empty, so those two gates were structurally incapable of failing and 7 of 10 hosts had no gate at all. New shape: one 'gen:skill-docs --host all' pass (the generator hard-fails on any per-host error, gating all 10 hosts on generates-cleanly), byte-freshness via git diff for tracked output, plus a porcelain check that fails on untracked generated strays (git diff can't see brand-new files). The gitignored-hosts byte-freshness limitation is documented in the workflow comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): exorcise the sidebar-agent ghost from the test suite browse/src/sidebar-agent.ts was deleted in the v1.14 sidebar refactor, but the test suite kept testing it for 48 versions. Nothing noticed because the free suite runs in no CI job and Bun-era module-load errors were suppressed in the Windows shard runner via an exclusion pattern whose own comment documented the breakage ('broken on every platform since v1.14 ... exit 0'). - Delete sidebar-security.test.ts + security-source-contracts.test.ts: crashed at module load (unguarded readFileSync of the deleted file); per-assertion triage confirmed every SERVER_SRC pin targeted the deleted chat prompt builder (zero hits in today's server.ts) — nothing to port. - Delete sidebar-integration.test.ts: 11 of 13 tests exercised deleted endpoints (/sidebar-command queue, /sidebar-agent/event, chat buffer); the 2 passing tests pinned only the blanket auth gate, covered by server-auth.test.ts + dual-listener.test.ts. - Delete test/skill-e2e-sidebar.test.ts: E2E for the deleted queue flow. - sidebar-ux.test.ts 1,669 -> 830 lines: 20 dead-chat describes + 15 dead tests removed (incl. 10 vacuous passes asserting on empty indexOf slices); 2 stale pins on LIVE features fixed (content.js typed-catch CSSOM fallback, arrow-hint window widened). 95 pass / 0 fail. - sidebar-tabs.test.ts: both failures were stale pins, not regressions — forceRestart's deliberate ws.close(4001) and the terminal-agent spawn that moved into spawnTerminalAgent() (identity-based kill refactor). 28 pass. - touchfiles.ts: drop the three sidebar E2E entries from BOTH maps (E2E_TOUCHFILES + E2E_TIERS) — they pointed diff-selection at the deleted file, so those tests were unreachable by any diff. - test-free-shards.ts: remove the now-dead sidebar-agent exclusion pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ci): run the free test suite in CI (it ran nowhere) The full free suite (bun test: browse/test/ + test/ + make-pdf/test/) had no CI job on any Linux/macOS runner — only Windows curated shards, paid evals, and doc-freshness gates existed. That's how two module-load-crashing test files survived 48 versions. Same cached Dockerfile.ci image and container wiring as evals.yml (deps restore, build, Chromium verify). Includes a module-load-error guard: older Bun reported test-file import crashes with exit 0 on macOS/Linux, so the job also fails on any nonzero 'N errors' count in the summary — future crash-class regressions can't hide from the exact job built to catch them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): validate touchfile dependency paths exist on disk New guard in touchfiles.test.ts: every non-glob dep path must exist, and every glob's anchor directory must exist. This is the axis the 181-key two-map sync discipline never covered — an entry can point at a long-deleted file and diff-based selection then silently never triggers those tests (the sidebar trio sat rotted for 48 versions). First run immediately caught a fourth rotted entry: 'spec authored quality' referenced test/fixtures/spec/** (directory does not exist) and selected for a judge test that exists nowhere in the repo. Removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): remove deleted /sidebar-chat endpoint from tunnel allowlist TUNNEL_PATHS is the audited tunnel attack surface — its own comment says every addition widens it. '/sidebar-chat' stayed in the set after the endpoint was deleted with the chat-queue path, meaning any future route matching that path would have been silently tunnel-exposed. The set is now exactly the pair ceremony (/connect) and the scoped command endpoint (/command), and the dual-listener closed-set pin enforces that. Also repairs a pre-existing red pin in dual-listener.test.ts: v1.63.0.0 made the tunnel allowlist args-aware (canDispatchOverTunnel gained a second param) without updating the test — red on main since then, invisible because the free suite had no CI job. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete chain's shadow dispatcher that skipped every security gate meta-commands.ts carried a 'CLI mode' fallback that re-implemented command routing without the server pipeline's gates: no scope check, no domain check, no tab ownership, no rate limit, no hidden-element stripping, no scoped-token enveloping — and it called handleReadCommand without a BrowserManager, which also skipped the JS-origin cookie-exfiltration assertion. It was unreachable in production (server.ts always passes executeCommand) and one boolean away from being live. chain now hard-errors without a server context. handleReadCommand's bm param is required and assertJsOriginAllowed runs unconditionally. The chain tests that exercised the deleted fallback now route through a server-shaped executeCommand adapter (real handlers + trust wrapping + {status,result} envelope), so their behavioral coverage — sequencing, trust markers, pipe format, aliases, error reporting — survives on the production-shaped path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(extension): delete the dead chat-queue client surface The sidebar-command handler in background.js POSTed to a server endpoint that no longer exists (deleted with the chat queue) — ~35 lines of fully-wired dead code including error handling for the permanent 404, plus its allowlist entry. No sender in the extension ever emitted the message type. chatEnabled leaves the /health contract (server hardcoded false, background.js re-derived it, nothing consumed it — the chat input element it guarded is gone from sidepanel.html). BROWSE_SIDEBAR_CHAT env flag had zero readers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete dead exports the ripped chat path left behind Three-way split by importer class: (a) Zero importers, deleted: the whole attack-attempt logging cluster in security.ts (logAttempt, AttemptRecord, salted hashPayload + device-salt, attempts.jsonl rotation, telemetry spawn plumbing incl. buildTelemetrySpawnCommand/resolveBashBinary — the LIVE attempts.jsonl writer is tunnel-denial-log.ts with its own rotation); the decision-file handshake (writeDecision/readDecision/clearDecision/excerptForReview — written for sidebar-agent's poll loop, which no longer exists); sidebar-utils.ts (whole module — its sanitizeExtensionUrl 'sanitized before embedding in a prompt' for the deleted prompt builder); 8 dead server.ts imports (sanitizeExtensionUrl, generateCanary, injectCanary, writeDecision, rotateRoot, serializeRegistry, restoreRegistry, clearAgentRecord); buildPtyClearCookie + buildSseClearCookie; WEBDRIVER_MASK_SCRIPT (orphaned by the D7 stealth narrowing — applyStealth never used it). (b) Dead-pin tests edited with their exports: the 'still exported' pin in stealth-layer-c, the string-content describe in stealth-webdriver (its live applyStealth behavioral coverage untouched), the clear-cookie assertions, security-review-flow.test.ts deleted whole (all 4 describes exercised the dead decision mechanism, incl. a 'simulated sidebar-agent poll loop'). (c) KEPT deliberately: leaseCount (live behavioral coverage), extractPtyCookie + validatePtySessionToken (extractPtyCookie is adopted by the terminal-agent cookie-parse unification later in this wave), resetSessionMarker + clearContentFilters (test-support API for the live content-security layer). Also fixes two pre-existing red pins found while here, invisible until the free suite got a CI job: the v1.44 spawnClaude->maybeSpawnPty rename in terminal-agent.test.ts, and a cross-file test-isolation bug where content-security.test.ts's clearContentFilters() wiped the auto-registered url-blocklist filter for every later file in the same bun process (security-integration.test.ts failed on co-run; afterAll now restores it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete the dead ML layers — transcript classifier and DeBERTa ensemble The L4b Haiku transcript classifier and the opt-in DeBERTa ensemble (GSTACK_SECURITY_ENSEMBLE=deberta, a documented 721MB download) had ZERO production callers since the chat-path agent that invoked them was ripped. The only live ML path is scanPageContent (testsavant) inside the security sidecar subprocess. Deleted by import graph: - security-classifier.ts 614 -> 265 lines: HAIKU_MODEL, checkTranscript, shouldRunTranscriptCheck, loadDeberta, scanPageContentDeberta, ToolCallInput, all DEBERTA_* consts + load state. Header now states the live truth (imported only by security-sidecar-entry.ts). downloadFile kept, name intact — it is an enumerated egress sink (HF model download). - security-bunnative.ts + test: a research skeleton self-described as 'NOT a production replacement', shipped into src/ with zero importers. - security-bench-ensemble{,-live}.test.ts + the Haiku response fixture: a paid live-model benchmark for a layer that could not fire. The security-classifier-tdz test's only case exercised checkTranscript — gone. - security.ts: layer-model header rewritten to the live architecture; StatusDetail.layers -> {testsavant, canary}; getStatus() no longer requires the impossible transcript==='ok' for 'protected' (old on-disk session state with a transcript key is tolerated on read, never re-emitted). - security-sidecar-entry.ts needed zero changes: it serializes getClassifierStatus() verbatim and no consumer read .transcript (verified in sidecar-client + server.ts). - BROWSER.md security section matches reality (ensemble knob gone, 112MB not 22MB, sidecar hosting documented). combineVerdict/THRESHOLDS retained as the pure, tested combiner of record — comments now flag transcript/deberta votes as producer-less. Net: 26 pass in security.test.ts incl. a NEW regression test for stale- transcript disk tolerance; egress-receipt tripwire green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: scrub the sidebar-agent ghost from comments and CLAUDE.md 20+ comments across 10 files still described the deleted sidebar-agent.ts as a live process — including load-bearing architecture claims ('IMPORTED ONLY BY sidebar-agent.ts', 'sidebar-agent fills this in on first prompt-injection load', 'kill sidebar-agent' in shutdown docs) and ~60 lines of tombstone blocks in server.ts enumerating deleted identifiers by name (a false grep surface: searching processAgentEvent hit server.ts and looked live). CLAUDE.md's security-stack section now documents the LIVE architecture: L1-L3 content filters + testsavant via the security sidecar subprocess; the L4b/ensemble rows, the GSTACK_SECURITY_ENSEMBLE knob, and the 721MB DeBERTa download are gone (deleted as dead code this wave) with an explicit do-not-re-document note; attempts.jsonl is correctly attributed to tunnel-denial-log.ts; the no-live-writer status of classifierStatus is stated. Comments that survive now describe what IS, not what WAS: the promotion gate in domain-skills.ts explains why classifier_score>0 is load-bearing given no L4 load-time scan exists; file-permissions.ts names real sensitive files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): delete the codex-helpers shadow module gen-skill-docs.ts imported externalSkillName (unaliased) from resolvers/codex-helpers.ts at line 21 and then re-declared the same function locally — the import was silently shadowed, and the imported copy was the STALE one (it lacked the frontmatterName param the local copy grew). Three more functions were byte-identical duplicates, imported only under _-prefixed aliases to keep the module 'referenced', and transformFrontmatter was a superseded hardcoded-Codex variant. Nothing else imported the module. Also drops three dead top-of-file imports (COMMAND_DESCRIPTIONS, SNAPSHOT_FLAGS — which pulled the whole browse/src module graph into every generator run for nothing — and an unused review-resolver trio). Proof: bun run gen:skill-docs exits 0 with a byte-identical tree (zero-diff regen); gen-skill-docs.test.ts 405/405 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(server): delete ServerConfig.idleTimeoutMs + chromiumProfile — documented, never read Both fields carried JSDoc asserting embedder behavior that did not exist: the idle check reads the module-level IDLE_TIMEOUT_MS env constant, and both resolveChromiumProfile() call sites pass no argument. Worse than absent — an embedder passing idleTimeoutMs: 5000 silently got 30 minutes. Wiring them honestly is impossible today: the idle timer, activity state, and shutdown target are module-global, so a per-factory value would lie for any process running more than one handler. Deleted instead, with a ServerConfig note pointing at the deferred singleton/route-table refactor where real support belongs. BROWSE_IDLE_TIMEOUT and CHROMIUM_PROFILE env remain the honest knobs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): wire appendSecureFile at the four real log-append sites file-permissions.ts carries a 24-line rationale for why POSIX mode bits are insufficient on Windows and implements appendSecureFile (0600 at create, Windows ACL on first write only) — but its single caller was the dead logAttempt, while the four REAL page-content log writers (console/network/ dialog logs in server.ts, the command audit log) used raw fs.appendFileSync with no mode. Page-content-derived logs now get owner-only permissions from birth on every platform. Verified before wiring: mode applies atomically at create via appendFileSync {mode}, and the ACL pass runs only on first write — no per-append subprocess cost on the hot console-log path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stealth): handoff() uses the shared profile resolution + lock cleanup The headless-to-headed handoff path hardcoded ~/.gstack/chromium-profile, silently ignoring $CHROMIUM_PROFILE and $GSTACK_HOME (gbrowser's gbd sets per-workspace profiles), and skipped cleanSingletonLocks() — so a handoff into a profile with a stale SingletonLock could hang where launchHeaded() would have recovered. This was the third live drift between the three Chromium launch paths; the first two are documented in comments as shipped stealth regressions. Minimal targeted fix — the full buildLaunchConfig() extraction stays in the deferred queue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): resolver registry describes the template language again Seven registered {{PLACEHOLDER}}s had zero uses in any .tmpl (checked in both bare and :arg forms): REDACT_TAXONOMY_TABLE, TEST_COVERAGE_AUDIT_REVIEW, MODEL_OVERLAY, QUESTION_PREFERENCE_CHECK, QUESTION_LOG, INLINE_TUNE_FEEDBACK, MAKE_PDF_SETUP. The last two of those families are invoked programmatically by preamble.ts (functions kept, registry entries dropped); the question-tuning trio and the review coverage-audit wrapper were documented by their own module as existing 'for unit testing' that no test performed — deleted, along with generateRedactTaxonomyTable + its EXAMPLE/TIER_BLURB constants (its '/cso renders the full table' comment was itself stale) and its test describe. Also deletes the gated-resolver mechanism (ResolverEntry/appliesTo/ unwrapResolver + test/resolver-entry.test.ts): fully built, fully tested, used by zero of the 65 registry entries — the generator loop simplifies to a direct function call. CLAUDE.md's redact-doc line stops advertising the dead token. Proof: zero-diff regen (0 SKILL.md changed); gen-skill-docs + skill-validation 737 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): wire boundaryInstruction from host config; drop three no-op binDir ternaries hosts/codex.ts declared boundaryInstruction and nothing read it — review.ts kept its own byte-identical CODEX_BOUNDARY literal (verified equal + trailing escaped newlines). The resolver now reads the config, so the boundary has one owner. (autoplan's template carries deliberately generic variants, enforced by gen-skill-docs.test.ts:1358 — untouched by design.) The 'ctx.host === codex ? $GSTACK_BIN : ctx.paths.binDir' ternary appeared in three resolvers and could never change the result: resolvers/types.ts already sets binDir to $GSTACK_BIN for every usesEnvVars host including codex. Proof: zero-diff regen for claude AND codex hosts; gen-skill-docs + host-config suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test-infra): judge uses resolveClaudeBinary; eval:watch reads the real partials dir judgePtyState spawned the bare string 'claude' three definitions below the resolveClaudeBinary() helper this same file exports — broken under hermetic PATHs where every other launch in the file resolves correctly. eval:watch read _partial-e2e.json from the legacy global ~/.gstack-dev/evals/ while EvalCollector writes it into the per-project eval dir (or GSTACK_EVAL_DIR) — so the dashboard's completed-tests panel was empty whenever slug detection succeeded, i.e. the normal case. The heartbeat and per-run progress logs stay global by design (session-runner.ts: 'heartbeat stays global'). The three eval-CLI docstrings stop claiming the legacy dir is the primary location. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): delete the superseded SDK ship-idempotency suite and three orphaned fixtures test/skill-e2e-ship-idempotency.test.ts's own header documented that the monolith's SDK-harness version tests a synthetic prompt while it exercises the real /ship skill — the author knew the old suite was superseded and left both running, two paid LLM runs for one behavior. The weaker copy is gone; its 'ship-idempotency' diff-selection key goes with it (the dedicated file is periodic-tier, which always runs under EVALS_ALL — the key had no remaining consumer). Fixture rot: test/fixtures/golden-ship-claude.md was a 128KB zero-reader orphan that had drifted 46KB from its live successor (test/fixtures/golden/claude-ship-SKILL.md) while looking authoritative; parity-baseline-v1.46.0.0.json and v1.53.0.0.json had zero readers (three tests pin three OTHER baseline versions — consolidation is queued, deletion of the unreferenced two is free). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): delete zero-caller scripts; make host-config-export's docstring honest - bin/gstack-open-url (14 lines): announced in a CHANGELOG entry, wired into nothing, ever. bin/gstack-platform-detect (27 lines): zero callers, and its hand-rolled host list was already stale (SLATE_HOST.md cites it as a problem). Note: the deprecated gstack-brain-consumer/reader pair the audit flagged was already deleted upstream in v1.63 with a stay-deleted tripwire. - scripts/task-emission-schema.ts (61 lines): a typed schema module nothing imported; the tasks-section comment now documents the JSONL fields inline. - scripts/host-config-export.ts claimed to be the 'shell bridge for the bash setup script' — setup never calls it (its hand-rolled host lists drifting is a known follow-up). Docstring now states what it IS: a standalone, test-pinned query CLI not yet wired into setup. Its validateValue + CLI_REGEX/PATH_REGEX internals were dead (defined for a guarantee the header claimed but nothing enforced). - KEPT deliberately: scripts/preflight-agent-sdk.ts — a documented manual diagnostic (CONTRIBUTING.md + USING_GBRAIN_WITH_GSTACK.md reference it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(server): one lone-surrogate sanitizer, one sanitizeReplacer, one startTunnel Three copies of the surrogate sanitizer existed with two algorithms (sanitize.ts regex vs a hand-rolled charCodeAt walk in server.ts — verified byte-identical across 11 edge cases before converging) plus two identical sanitizeReplacer definitions each wrapping a different copy. sanitize.ts is now the single source of truth; the runs-INSIDE-JSON.stringify egress invariant is unchanged at every call site and its pin tests were adapted to the new import shape without losing intent. The ngrok tunnel-start sequence existed three times in server.ts — the /tunnel/start route and the BROWSE_TUNNEL=1 autostart were line-for-line equivalent (a comment admitted 'Same cleanup as /tunnel/start's error path'). One startTunnel() now owns the ephemeral loopback bind, the pre-send egress receipt, the state-file RMW via tmpStatePath(), and the ordered error-path cleanup; callers keep their distinct response surfaces. The BROWSE_TUNNEL_LOCAL_ONLY test path shares nothing (no ngrok, different state field) and deliberately stays separate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): one session-cookie registry implementation, two instances pty-session-cookie.ts and sse-session-cookie.ts were byte-identical modulo the cookie name — mint/validate/parse/prune/TTL, the exact code a security fix would have to land in twice (and a third hand-rolled cookie parse in terminal-agent.ts had already diverged; unified next commit). createSessionCookieStore() owns the implementation; both modules become thin instantiations keeping every exported name, their distinct threat-model docstrings, and separate token spaces (an SSE-read cookie must never grant PTY access). pty-session-lease.ts deliberately stays out — different contract (sessionId/secret split, refresh, env TTL). The factory imports nothing from token-registry (cookie-picker-auth-isolation invariant, still pinned by sse-session-cookie.test.ts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): terminal-agent uses the shared PTY cookie parser The /ws upgrade's cookie fallback hand-parsed the Cookie header inline — the fourth copy of the session-cookie parse, and the one that had already diverged from the others. Parsing now goes through extractPtyCookie; validation deliberately stays against the agent's own in-process validTokens map (the server's registry lives in a different process). The ws-handler pin test now pins the shared-parser call instead of the raw cookie-name literal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(hosts): defineHost() factory — 10 copy-paste host files become declarations hosts/*.ts were ten copies of one file: runtimeRoot byte-identical in 9/10, pathRewrites mechanically derivable from the host name for 7/10, the 11-entry toolRewrites map byte-identical between openclaw and gbrain, and every asset change a 10-file edit (cursor and slate had already fallen out of three other hand-maintained lists). defineHost() owns the defaults; each host file now declares only what makes it different (slate/cursor: 8 lines each). Shared constants: CROSS_MODEL_RESOLVERS, GBRAIN_RESOLVERS, EXEC_STYLE_TOOL_REWRITES. Genuinely-different things stayed explicit: codex/factory $GSTACK_ROOT rewrites, hermes's tool vocabulary, claude's denylist+prefixable install, opencode's wider runtimeRoot. Proof: JSON.stringify(ALL_HOST_CONFIGS) dump-diff before/after EMPTY (and a runtime walk confirmed no function-valued or undefined-keyed fields, so the JSON diff is complete); gen:skill-docs --host all zero-diff; host-config + gen-skill-docs + idempotency suites 485/485. Host files 595 -> 285 lines. docs/ADDING_A_HOST.md teaches the factory pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(lib): fs-atomic — one atomic-write implementation, with the race actually fixed Atomic tmp-write-then-rename was reimplemented ~20 times across lib/, bin/, and browse/src with three tmp-suffix conventions. One of them was a latent bug this commit closes: lib/worktree.ts used a bare '.tmp' suffix — the deterministic-tmp collision race browse/src/server.ts documents having hit in production (its fix, pid+random, was trapped in a comment at one site). lib/fs-atomic.ts: atomicWriteSync (always throws, best-effort tmp cleanup, pid+random suffix, optional mode applied at tmp creation so the file never exists with looser permissions) + atomicWriteQuiet (shutdown paths only). Unit tests pin the throw/quiet contracts, 0600 mode, tmp-name uniqueness (captured via the read-only-dir failure path — Bun's fs exports are readonly, no monkeypatching), and no-stray-tmp cleanup. Migrated: lib/worktree.ts (the bare-.tmp bug), lib/gstack-decision.ts (snapshot + compact log), lib/gbrain-local-status.ts (probe cache). browse sites follow separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(lib): jsonl-store's docstring stops lying; mode option added; lib bypasses adopted The header claimed 'single source of truth... the ONLY copy' with write-time injection REJECTION — while appendJsonl never screened anything, only 1 of ~10 JSONL stores imported it, and a bypass appender lived in the same directory. Now: the contract is explicit (screening is the CALLER's job via hasInjection/firstInjectionMatch; the enforcing callers are named), a option applies 0600 at create for sensitive stores, and the lib bypasses are adopted (gstack-memory-helpers ×2, redact-audit-log — which keeps its chmod backstop for files created looser by pre-mode versions). browse/src keeps its own appenders by design (compiled-binary surface, own secure-append helper) and the header now says so. gstack-decision's batched archive append stays deliberate (single-write crash-window semantics appendJsonl's one-record contract can't express). New pins: 0600-at-create, and a test that documents appendJsonl does NOT self-screen — so nobody can re-document it as self-screening without making it true. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): migrate hand-rolled atomic writes to lib/fs-atomic Seven sites, each audited for its existing throw-vs-swallow contract before migrating: writeSessionState + the four fire-and-forget tab/state writers use atomicWriteQuiet (they swallowed before); writeAgentRecord + the boot-time port-file write use atomicWriteSync (they threw before — and writeAgentRecord previously leaked its tmp file on rename failure, which the helper cleans). All carry {mode: 0o600} plus restrictFilePermissions after successful writes, preserving the Windows ACL hardening that writeSecureFile provided (mode bits are POSIX-only). server.ts untouched: its three state writes route through tmpStatePath(), pinned by server-tmp-state-path.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(hosts): delete five dead HostConfig fields metadataFormat (generator hardcodes openai.yaml), sidecar (behavior lives in setup's create_agents_sidecar — knowledge preserved as a comment in codex.ts), install.prefixable (skill_prefix is implemented entirely in bin/gstack-config), staticFiles (docstring cited a SOUL.md that never existed anywhere), and adapter (its only would-be consumer, openclaw-adapter.ts, was fully dead — with a test asserting the field was undefined). Kept: learningsMode (wired next), linkingStrategy (validation reads it), coAuthorTrailer (consumed by resolvers/utility.ts). Proof: JSON dump diff shows ONLY the deleted keys vanishing; zero-diff regen across all 10 hosts; host-config + gen-skill-docs suites green. Note: this commit also carries chunk-23 edits to the shared hosts/claude.ts + define-host.ts + host-config.test.ts files (skipSkills collapse, stale line-number comment drops) — pathspec commits, concurrent prep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): preamble tiers are explicit; silent ?? 4 default becomes an error; spec stops rendering its preamble twice Eight skills (scrape, diagram, spec, skillify, pair-agent, landing-report, open-gstack-browser + its connect-chrome symlink) silently received the HEAVIEST tier-4 preamble because a missing frontmatter field defaulted to 4. Tiers are now declared in every {{PREAMBLE}} template's frontmatter and a missing declaration throws at generation time with the template path (the 5 templates without {{PREAMBLE}} never invoke the resolver). The stale hand-written tier-map comment (wrong in 3 of 4 rows) is gone. Bonus bug fixed: spec/SKILL.md.tmpl mentioned {{PREAMBLE}} in prose, so the generator inlined the ENTIRE preamble a second time — spec/SKILL.md shrinks 127,462 -> 80,924 bytes (-46,538) from de-duplication alone. skill-size-budget gains a reasoned INTENTIONAL_SHRINKS entry (its frozen baseline had measured the doubled-preamble bug). New tests: missing-tier throw carries the path; every {{PREAMBLE}} template declares a tier. (Carries chunk-23 edits in the shared test/gen-skill-docs.test.ts.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): learningsMode is read from host config, not a hardcoded host name resolvers/learnings.ts branched on ctx.host === 'codex' while every host declared learningsMode — the field was decorative, and the 7 hosts configured 'basic' (cursor, slate, kiro, opencode, openclaw, hermes, gbrain) silently received the 'full' cross-project flow their runtimes can't execute (it depends on AskUserQuestion + gstack-config plumbing). Output now matches declaration: basic hosts get the project-scoped search block. Blast radius proof: all committed Claude SKILL.md files and the three golden fixtures are byte-identical; the behavior diff lands only in the gitignored external-host trees (hand-verified: .cursor review's learnings section swaps the cross-project AskUserQuestion block for the project-scoped search). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): small config scrubs — openclaw blobs to real files, setup host drift, dead artifacts - The three openclaw markdown blobs hardcoded inside gen-skill-docs.ts (which silently reverted any hand edit to their tracked outputs on regen) move to openclaw/templates/*.md source files; output shasums byte-identical. - setup's --host allowlists gain cursor + slate — both fully registered hosts with generated output, but './setup --host cursor' exited 1 because two hand-rolled lists in setup had drifted from hosts/index.ts. - scripts/proactive-suggestions.json deleted: 31KB regenerated on every run, read by nobody (the catalog-trim design's reader was never built); its emitter and three determinism tests (which guaranteed a file nothing reads didn't churn) retired with stays-retired pins. - claude/SKILL.md.tmpl deleted: a complete 8.9KB skill that never generated output (directory name collides with the host id 'claude'), in no registry. Recoverable from git if ever wanted under a non-colliding name. - openclaw's frozen extraFields.version '0.15.2.0' stamp dropped; includeSkills: [] no-ops omitted (the generator treats [] as absent); llms.txt 55 -> 54 skills. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(gen): correct preamble tiers for the 8 silently-heaviest skills With tiers now explicit, set them RIGHT by analogy to the tiered population: scrape/diagram/open-gstack-browser (+ the connect-chrome symlink) -> tier 1 (launchers and artifact generators, like browse and make-pdf); landing-report/pair-agent/skillify -> tier 2 (dashboards and session tools, like health and canary); spec -> tier 3 (interactive planning, like the plan-*-review family). Each tier-1 skill sheds 271 lines of onboarding prose it never needed; tier-2 shed 20 each. Verification per the review protocol: regen diff reviewed (pure section-removal), skill-validation + size-budget + catalog-budget + v0-dormancy suites green (822 tests), and live smoke of the tier-corrected skills confirms the preamble renders the intended sections at each tier. These skills have ~no eval coverage — stated honestly; the wave's gate-tier eval run is the backstop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): e2e-gate — one tier-gate implementation, side-effect-free, with the trap pinned The EVALS/EVALS_TIER gate was copy-pasted into ~40 test files and had drifted into six different predicates — the drift that made 'eval:bg:all runs everything' silently false. test/helpers/e2e-gate.ts owns the semantics now: describeE2ETier(tier) + e2eTierEnabled(tier), env read at call time, zero side effects (the existing e2e-helpers module runs a ~30s claude ping at import under EVALS=1, so the gate lives in its own module; purity is pinned by tests that scan imports and comment-stripped source). The unit matrix pins all four env combos — including EVALS=1 with EVALS_TIER unset -> SKIP, the exact trap that made eval:bg:all a non-run. The tier-alignment tripwire gains a second regex for the helper shape (old shape still detected — stragglers can't hide), and the sharded paid runner's PRE-SPAWN tier classifier learns the helper shape too: without that, every gate-sharded run would have spawned all 28 periodic shards just to skip them, each paying the e2e-helpers import ping (~15 min of dead wall clock in the CI-blocking lane). Verified: gate runs exclude the 29 periodic files, periodic excludes the 8 gate files — identical to pre-migration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(test): migrate the 36 tier-gated eval files to describeE2ETier Mechanical two-liner swap in 34 files (each keeping its declared tier — all 36 predicates verified against E2E_TIERS before migrating); the two files with compound gates (overlay-harness's EvalCollector feed, codex-e2e's CODEX_AVAILABLE) keep their extra conditions via e2eTierEnabled. Tier rationale comments preserved. codex-e2e/gemini-e2e/benchmark-providers keep their distinct stderr-message gate shapes by design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(test): skill-e2e + skill-llm-eval adopt the shared selection machinery Both files re-implemented the diff-selection machinery e2e-helpers already exported. The helper gained computeDiffSelection() (extracted, identical behavior) and a trailing optional selection param on the *IfSelected helpers (defaults preserve all 30+ existing importers). skill-e2e.test.ts drops ~120 duplicated lines; skill-llm-eval keeps its LLM_JUDGE_TOUCHFILES selection and test.concurrent semantics via testConcurrentIfSelected. Deliberate deltas, stated: skill-e2e.test.ts now honors the EVALS_TIER intersection its local copy lacked (affects only direct bun test invocations of that file — it matches no eval-script glob); its recordE2E gains the helper's three diagnostic fields; skill-llm-eval sharded solo now runs e2e-helpers' module-scope preflight it already ran in combined processes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): kill the silent-truncation race; exempt the tier-corrected shrinks The full-suite shakeout (budgeted by the plan) surfaced both immediately: 1. server-embedder-terminal-port.test.ts stubbed process.exit and restored the REAL exit in its finally — but shutdown() schedules async work that can call process.exit AFTER restoration, killing the entire bun process mid-suite with exit 0 and NO summary. This is the silent-truncation class the new free-suite CI job guards against, reproduced locally on the first full run. Exit now stays a logging no-op between tests (late async exits become visible stderr lines, not process death); the true exit returns in afterAll. 2. The 80%-of-baseline shrink guard correctly flagged the six tier-corrected skills — their baseline was measured at the silent tier-4 default. Added to INTENTIONAL_SHRINKS with the reason, joining spec's double-preamble entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * release: v1.64.0.0 — the code-smell fix wave 35 commits, one PR: guard repairs (free suite in CI per-file, all-host freshness gates, tunnel allowlist, diff-selection validation), the sidebar-agent ghost exorcism (dead ML layers, dead endpoints, dead exports, ghost comments), config honesty (defineHost factory, dead fields deleted, preamble tiers explicit, spec double-render fixed), and dedup with safety nets (session-cookie factory, fs-atomic, jsonl-store contract, one eval tier-gate). Net -24,943 lines across 183 files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests step runs under bash (container sh rejects pipefail) Maiden-voyage shakeout, exactly as budgeted: the CI container's default shell is dash, which errors on 'set -o pipefail' before the first test ran. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests curates 8 container-incompatible files with reasons Second maiden-voyage shakeout round: 376 of 384 files ran green in the container on the first completed pass. The 8 that can't run there yet are excluded the same way the Windows shards curate POSIX-bound files — each with its reason inline (headed-Chrome handoff, real-PTY round-trip, X server management, extension-origin identity, the job's own TMPDIR override, and three pre-existing env failures that fail on dev machines too). Anything outside the list that fails still fails the job; trimming the list is tracked follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): gstack-config-key-locale — suppress the skill_prefix auto-relink side effect The test invokes the repo's own bin/gstack-config, whose 'set skill_prefix' auto-runs $(dirname $0)/gstack-relink — resolving the install dir to the repo itself. In any environment where the loop shares a working tree (the free-tests CI container, a fresh-HOME run), gstack-patch-names rewrote all 52 tracked SKILL.md names to gstack- prefixed, poisoning five unrelated suites downstream (hermetic-skills-seeding, host-config golden, skill-census, skill-validation, spec-template-sync). GSTACK_SETUP_RUNNING=1 is the documented suppression; relink behavior stays covered by relink.test.ts's mock install. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): gstack-codex-session-import — empty sessions dir exits 0 on Linux GNU xargs runs 'ls -t' once even on empty input, listing the cwd and producing a bogus LATEST from the repo root; BSD xargs (macOS) skips the run, which is why the NO_SESSIONS path only broke on Linux. xargs -r pins the BSD behavior on both platforms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(parity): rebaseline v1.57.7.0 → v1.64.1.0 + skeleton-cap headroom The two parallel v1.64 waves (code-smell fix wave + main's #2571) each added shared-preamble prose, pushing document-release / design-consultation / cso past their size ratios on the v1.57.7.0 anchor and four carved skeletons (plan-ceo-review, plan-eng-review, office-hours, design-consultation) 22-280 B over their absolute caps. New baseline is union-normalized (skeleton + sections/*.md, matching what the harness measures); caps get +~1 KB headroom each with per-cap rationale. The v1.57.7.0 fixture stays in test/fixtures/ for the audit trail, and capture-parity-baseline.ts now documents the union-normalization step so the next rebaseline doesn't re-trip on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests container parity — tools, pinned bun, git identity, mutation tripwire - Dockerfile.ci: add python3 (gstack-jsonl-merge/brain-sync/detach shell out to it), file (skill-validation's binary check), poppler-utils (make-pdf e2e gates hard-require pdftotext/pdffonts/pdfinfo), fonts-noto-color-emoji (emoji render gate, mirrors make-pdf-gate.yml). Fix the bun pin: the bun.sh installer ignores a BUN_VERSION env var, so the old form silently installed latest on every rebuild (observed 1.3.13/1.3.14 drift vs the 1.3.10 devs run locally); pass the version as the positional arg. - free-tests.yml: git identity + safe.directory for the git-exercising tests (container checkout is owned by a different uid than runner); post-loop tree-mutation tripwire that names a tracked-file-mutating test instead of letting downstream collateral confuse the report; skip the documented variants-retry-after timing flake. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): gstack-session-update — detached updater owns its stdio (SIGPIPE) The backgrounded update subshell inherited the session hook's stdout/stderr pipes. Once the hook exits and the caller closes them, any child that writes — git pull's autostash notice, setup output — dies of SIGPIPE, logged as PULL_FAILED exit=141 with an empty stderr capture (observed in the free-tests container, and reachable by any production hook runner that closes stdio promptly). Redirect the fork to /dev/null; all observability already flows through the session-update log file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): gstack-decision-bins — explicit branch context for the scope filter CI checks out a detached HEAD, where gitBranch() returns undefined on both the log and search sides, so an implicitly branch-scoped decision can never surface (filterByScope requires a matching non-empty ctx.branch). Pass the branch explicitly on both sides — the filter logic is what's under test, not git branch detection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): ring-buffer lease interplay — same TTL window, not same millisecond Two back-to-back mintLease() calls each stamp Date.now() + TTL; when they straddle a millisecond boundary the exact-equality assertion flakes (observed in CI: expiries of ...525 vs ...526). Assert the expiries are within a 50 ms window instead — the invariant under test is that leases share a TTL policy, not that they mint in the same clock tick. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1075 lines
62 KiB
Markdown
1075 lines
62 KiB
Markdown
# gstack development
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
bun install # install dependencies
|
|
bun test # run free tests (browse + snapshot + skill validation)
|
|
bun run test:evals # run paid evals: LLM judge + E2E (diff-based, ~$4/run max)
|
|
bun run test:evals:all # run ALL paid evals regardless of diff
|
|
bun run test:gate # run gate-tier tests only (CI default, blocks merge)
|
|
bun run test:periodic # run periodic-tier tests only (weekly cron / manual)
|
|
bun run test:gate:sharded # gate tier via the sharded paid runner (one Bun process per test file)
|
|
bun run test:periodic:sharded # periodic tier via the sharded paid runner (implies EVALS_ALL=1)
|
|
bun run test:e2e # run E2E tests only (diff-based, ~$3.85/run max)
|
|
bun run test:e2e:all # run ALL E2E tests regardless of diff
|
|
bun run eval:select # show which tests would run based on current diff
|
|
bun run dev <cmd> # run CLI in dev mode, e.g. bun run dev goto https://example.com
|
|
bun run build # gen docs + compile binaries
|
|
bun run gen:skill-docs # regenerate SKILL.md files from templates
|
|
bun run skill:check # health dashboard for all skills
|
|
bun run dev:skill # watch mode: auto-regen + validate on change
|
|
bun run eval:list # list all eval runs from ~/.gstack-dev/evals/
|
|
bun run eval:compare # compare two eval runs (auto-picks most recent)
|
|
bun run eval:summary # aggregate stats across all eval runs
|
|
bun run slop # full slop-scan report (all files)
|
|
bun run slop:diff # slop findings in files changed on this branch only
|
|
```
|
|
|
|
`test:evals` requires `ANTHROPIC_API_KEY`. Codex E2E tests (`test/codex-e2e.test.ts`)
|
|
use Codex's own auth from `~/.codex/` config — no `OPENAI_API_KEY` env var needed.
|
|
|
|
**Env keys in Conductor workspaces.** The `GSTACK_*` env-shim (v1.39.2.0+,
|
|
`lib/conductor-env-shim.ts`) promotes `GSTACK_ANTHROPIC_API_KEY` /
|
|
`GSTACK_OPENAI_API_KEY` to their canonical names inside gstack's TS binaries.
|
|
Tests run through gstack entrypoints inherit this promotion automatically.
|
|
Don't echo the key value to stdout, logs, or shell history. The historical
|
|
"never pass `env:` to `runAgentSdkTest`" rule is retired: the failure was
|
|
partial-env replacement (the SDK's `Options.env` REPLACES the child's entire
|
|
environment, so an object without the key broke auth). The runner now always
|
|
passes a COMPLETE hermetic env with per-test `env:` merged last, so per-test
|
|
overrides are safe; ambient `process.env.ANTHROPIC_API_KEY` mutation also
|
|
still works (the env builder reads process.env at call time).
|
|
|
|
**Hermetic local E2E (default).** Every E2E runner (claude -p, PTY, Agent
|
|
SDK, codex, gemini) spawns children through `test/helpers/hermetic-env.ts`:
|
|
allowlist-scrubbed env (operator `CONDUCTOR_*`, `CLAUDE_*`, `GSTACK_*`,
|
|
`MCP_*`, `GBRAIN_*`, and credentials like `GH_TOKEN` never reach children),
|
|
a fresh seeded `CLAUDE_CONFIG_DIR` (no operator `~/.claude` CLAUDE.md /
|
|
MCP servers / skills), a temp `GSTACK_HOME`, and `--strict-mcp-config`.
|
|
Local eval signal matches CI. Debug against real operator state with
|
|
`EVALS_HERMETIC=0` (restores the legacy env AND drops the strict-MCP flag).
|
|
Per-test `env:` overrides merge last, so deliberate contamination
|
|
(`CONDUCTOR_WORKSPACE_PATH`, per-test `GSTACK_HOME`) keeps working. The
|
|
hermetic config dir seeds NO skills by default; a PTY test that types a
|
|
`/skill` slash command must pass `seedSkills: true` to the PTY runner, which
|
|
points the child's `CLAUDE_CONFIG_DIR` at `hermeticSkillsConfigDir()` — a
|
|
seeded registry that symlinks the LIVE working tree's SKILL.md files (by
|
|
design: the skills ARE the subject under test; a snapshot would measure stale
|
|
copies). Wiring is pinned by `test/hermetic-wiring.test.ts` (static tripwire),
|
|
two gate-tier canaries in `test/skill-e2e-hermetic-canary.test.ts`, and the
|
|
seeding tripwires in `test/hermetic-skills-seeding.test.ts` /
|
|
`test/pty-skill-seeding-wiring.test.ts`.
|
|
|
|
E2E tests stream progress in real-time (tool-by-tool via `--output-format stream-json
|
|
--verbose`). Results are persisted to `~/.gstack-dev/evals/` with auto-comparison
|
|
against the previous finalized run (in-flight `_partial` files are never used as
|
|
a baseline, so a run can't compare against itself).
|
|
|
|
**Diff-based test selection:** `test:evals` and `test:e2e` auto-select tests based
|
|
on `git diff` against the base branch. Each test declares its file dependencies in
|
|
`test/helpers/touchfiles.ts`. Changes to global touchfiles (session-runner, eval-store,
|
|
touchfiles.ts itself) trigger all tests. Use `EVALS_ALL=1` or the `:all` script
|
|
variants to force all tests. Run `eval:select` to preview which tests would run.
|
|
|
|
**Two-tier system:** Tests are classified as `gate` or `periodic` in `E2E_TIERS`
|
|
(in `test/helpers/touchfiles.ts`). CI runs only gate tests (`EVALS_TIER=gate`);
|
|
periodic tests run weekly via cron or manually. Use `EVALS_TIER=gate` or
|
|
`EVALS_TIER=periodic` to filter. When adding new E2E tests, classify them:
|
|
1. Safety guardrail or deterministic functional test? -> `gate`
|
|
2. Quality benchmark, Opus model test, or non-deterministic? -> `periodic`
|
|
3. Requires external service (Codex, Gemini)? -> `periodic`
|
|
|
|
Tier declarations are enforced by `test/e2e-tier-alignment.test.ts` (free, runs
|
|
in `bun test`): a `skill-e2e-*` file named in a touchfiles dep list whose
|
|
`EVALS_TIER` self-gate disagrees with its declared tier in `E2E_TIERS` fails the
|
|
suite. Files not named in any dep list are reported, not enforced — keep both
|
|
in sync.
|
|
|
|
## Testing
|
|
|
|
```bash
|
|
bun test # run before every commit — free, <2s
|
|
bun run test:evals # run before shipping — paid, diff-based (~$4/run max)
|
|
```
|
|
|
|
`bun test` runs skill validation, gen-skill-docs quality checks, and browse
|
|
integration tests. `bun run test:evals` runs LLM-judge quality evals and E2E
|
|
tests via `claude -p`. Both must pass before creating a PR.
|
|
|
|
## Project structure
|
|
|
|
```
|
|
gstack/
|
|
├── browse/ # Headless browser CLI (Playwright)
|
|
│ ├── src/ # CLI + server + commands
|
|
│ │ ├── commands.ts # Command registry (single source of truth)
|
|
│ │ └── snapshot.ts # SNAPSHOT_FLAGS metadata array
|
|
│ ├── test/ # Integration tests + fixtures
|
|
│ └── dist/ # Compiled binary
|
|
├── hosts/ # Typed host configs (one per AI agent)
|
|
│ ├── claude.ts # Primary host config
|
|
│ ├── codex.ts, factory.ts, kiro.ts # Existing hosts
|
|
│ ├── opencode.ts, slate.ts, cursor.ts, openclaw.ts # IDE hosts
|
|
│ ├── hermes.ts, gbrain.ts # Agent runtime hosts
|
|
│ └── index.ts # Registry: exports all, derives Host type
|
|
├── scripts/ # Build + DX tooling
|
|
│ ├── gen-skill-docs.ts # Template → SKILL.md generator (config-driven)
|
|
│ ├── host-config.ts # HostConfig interface + validator
|
|
│ ├── host-config-export.ts # Shell bridge for setup script
|
|
│ ├── resolvers/ # Template resolver modules (preamble, design, review, gbrain, etc.)
|
|
│ ├── skill-check.ts # Health dashboard
|
|
│ ├── test-paid-shards.ts # Sharded paid-tier runner (one Bun process per shard)
|
|
│ └── dev-skill.ts # Watch mode
|
|
├── test/ # Skill validation + eval tests
|
|
│ ├── helpers/ # skill-parser.ts, session-runner.ts, llm-judge.ts, eval-store.ts
|
|
│ ├── fixtures/ # Ground truth JSON, planted-bug fixtures, eval baselines
|
|
│ ├── skill-validation.test.ts # Tier 1: static validation (free, <1s)
|
|
│ ├── gen-skill-docs.test.ts # Tier 1: generator quality (free, <1s)
|
|
│ ├── skill-llm-eval.test.ts # Tier 3: LLM-as-judge (~$0.15/run)
|
|
│ └── skill-e2e-*.test.ts # Tier 2: E2E via claude -p (~$3.85/run, split by category)
|
|
├── qa-only/ # /qa-only skill (report-only QA, no fixes)
|
|
├── plan-design-review/ # /plan-design-review skill (report-only design audit)
|
|
├── design-review/ # /design-review skill (design audit + fix loop)
|
|
├── ship/ # Ship workflow skill
|
|
├── review/ # PR review skill
|
|
├── plan-ceo-review/ # /plan-ceo-review skill
|
|
├── plan-eng-review/ # /plan-eng-review skill
|
|
├── autoplan/ # /autoplan skill (auto-review pipeline: CEO → design → eng)
|
|
├── benchmark/ # /benchmark skill (performance regression detection)
|
|
├── canary/ # /canary skill (post-deploy monitoring loop)
|
|
├── codex/ # /codex skill (multi-AI second opinion via OpenAI Codex CLI)
|
|
├── land-and-deploy/ # /land-and-deploy skill (merge → deploy → canary verify)
|
|
├── office-hours/ # /office-hours skill (YC Office Hours — startup diagnostic + builder brainstorm)
|
|
├── investigate/ # /investigate skill (systematic root-cause debugging)
|
|
├── spec/ # /spec skill (five-phase spec → GitHub issue, optional agent spawn, /ship auto-closes)
|
|
├── retro/ # Retrospective skill (includes /retro global cross-project mode)
|
|
├── bin/ # CLI utilities (gstack-repo-mode, gstack-slug, gstack-config, etc.)
|
|
├── document-release/ # /document-release skill (post-ship doc updates + Diataxis coverage map)
|
|
├── document-generate/ # /document-generate skill (Diataxis doc generator: tutorial/how-to/reference/explanation)
|
|
├── cso/ # /cso skill (OWASP Top 10 + STRIDE security audit)
|
|
├── design-consultation/ # /design-consultation skill (design system from scratch)
|
|
├── design-shotgun/ # /design-shotgun skill (visual design exploration)
|
|
├── open-gstack-browser/ # /open-gstack-browser skill (launch GStack Browser)
|
|
├── connect-chrome/ # symlink → open-gstack-browser (backwards compat)
|
|
├── design/ # Design binary CLI (GPT Image API)
|
|
│ ├── src/ # CLI + commands (generate, variants, compare, serve, etc.)
|
|
│ ├── test/ # Integration tests
|
|
│ └── dist/ # Compiled binary
|
|
├── extension/ # Chrome extension (side panel + activity feed + CSS inspector)
|
|
├── lib/ # Shared libraries (worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts)
|
|
├── docs/designs/ # Design documents
|
|
├── setup-deploy/ # /setup-deploy skill (one-time deploy config)
|
|
├── .github/ # CI workflows + Docker image
|
|
│ ├── workflows/ # evals.yml (E2E on Ubicloud), skill-docs.yml, actionlint.yml
|
|
│ └── docker/ # Dockerfile.ci (pre-baked toolchain + Playwright/Chromium)
|
|
├── contrib/ # Contributor-only tools (never installed for users)
|
|
│ └── add-host/ # /gstack-contrib-add-host skill
|
|
├── setup # One-time setup: build binary + symlink skills
|
|
├── SKILL.md # Generated from SKILL.md.tmpl (don't edit directly)
|
|
├── SKILL.md.tmpl # Template: edit this, run gen:skill-docs
|
|
├── ETHOS.md # Builder philosophy (Boil the Ocean, Search Before Building)
|
|
└── package.json # Build scripts for browse
|
|
```
|
|
|
|
## SKILL.md workflow
|
|
|
|
SKILL.md files are **generated** from `.tmpl` templates. To update docs:
|
|
|
|
1. Edit the `.tmpl` file (e.g. `SKILL.md.tmpl` or `browse/SKILL.md.tmpl`)
|
|
2. Run `bun run gen:skill-docs` (or `bun run build` which does it automatically)
|
|
3. Commit both the `.tmpl` and generated `.md` files
|
|
|
|
To add a new browse command: add it to `browse/src/commands.ts` and rebuild.
|
|
To add a snapshot flag: add it to `SNAPSHOT_FLAGS` in `browse/src/snapshot.ts` and rebuild.
|
|
|
|
**Token ceiling:** Generated SKILL.md files trip a warning above 160KB (~40K tokens).
|
|
This is a "watch for feature bloat" guardrail, not a hard gate. Modern flagship
|
|
models have 200K-1M context windows, so 40K is 4-20% of window, and prompt caching
|
|
makes the marginal cost of larger skills small. The ceiling exists to catch runaway
|
|
preamble/resolver growth, not to force compression on carefully-tuned big skills
|
|
(`ship`, `plan-ceo-review`, `office-hours` legitimately pack 25-35K tokens of
|
|
behavior). If you blow past 40K, the right fix is usually: (1) look at WHAT grew,
|
|
(2) if one resolver added 10K+ in a single PR, question whether it belongs inline
|
|
or as a reference doc, (3) only compress carefully-tuned prose as a last resort —
|
|
cuts to the coverage audit, review army, or voice directive have real quality cost.
|
|
|
|
A second, harder ceiling guards the DISCOVERY surface: `test/catalog-budget.test.ts`
|
|
caps the aggregate frontmatter `name` + `description` across all skills at 1,150
|
|
token-equivalents (260-byte per-skill sub-cap), counted through the shared census
|
|
in `test/helpers/skill-census.ts`. This one is enforced, not a warning — every
|
|
host loads the full catalog every session, so growth here taxes every
|
|
conversation. The failure message carries the re-measure + ratchet protocol.
|
|
`bin/gstack-context-bill` shows the full token bill-of-materials for a skills
|
|
tree (always-on vs per-invocation, `--diff`, `--budget`; `--exact` opts into the
|
|
real tokenizer and POSTs file text to api.anthropic.com with an egress receipt).
|
|
|
|
**Merge conflicts on SKILL.md files:** NEVER resolve conflicts on generated SKILL.md
|
|
files by accepting either side. Instead: (1) resolve conflicts on the `.tmpl` templates
|
|
and `scripts/gen-skill-docs.ts` (the sources of truth), (2) run `bun run gen:skill-docs`
|
|
to regenerate all SKILL.md files, (3) stage the regenerated files. Accepting one side's
|
|
generated output silently drops the other side's template changes.
|
|
|
|
## Platform-agnostic design
|
|
|
|
Skills must NEVER hardcode framework-specific commands, file patterns, or directory
|
|
structures. Instead:
|
|
|
|
1. **Read CLAUDE.md** for project-specific config (test commands, eval commands, etc.)
|
|
2. **If missing, AskUserQuestion** — let the user tell you or let gstack search the repo
|
|
3. **Persist the answer to CLAUDE.md** so we never have to ask again
|
|
|
|
This applies to test commands, eval commands, deploy commands, and any other
|
|
project-specific behavior. The project owns its config; gstack reads it.
|
|
|
|
## Writing SKILL templates
|
|
|
|
SKILL.md.tmpl files are **prompt templates read by Claude**, not bash scripts.
|
|
Each bash code block runs in a separate shell — variables do not persist between blocks.
|
|
|
|
Rules:
|
|
- **Use natural language for logic and state.** Don't use shell variables to pass
|
|
state between code blocks. Instead, tell Claude what to remember and reference
|
|
it in prose (e.g., "the base branch detected in Step 0").
|
|
- **Don't hardcode branch names.** Detect `main`/`master`/etc dynamically via
|
|
`gh pr view` or `gh repo view`. Use `{{BASE_BRANCH_DETECT}}` for PR-targeting
|
|
skills. Use "the base branch" in prose, `<base>` in code block placeholders.
|
|
- **Keep bash blocks self-contained.** Each code block should work independently.
|
|
If a block needs context from a previous step, restate it in the prose above.
|
|
- **Express conditionals as English.** Instead of nested `if/elif/else` in bash,
|
|
write numbered decision steps: "1. If X, do Y. 2. Otherwise, do Z."
|
|
|
|
## Writing style (V1)
|
|
|
|
Default output from every tier-≥2 skill follows the Writing Style section in
|
|
`scripts/resolvers/preamble.ts`: jargon glossed on first use (curated list in
|
|
`scripts/jargon-list.json`, baked at gen-skill-docs time), questions framed in
|
|
outcome terms ("what breaks for your users if...") not implementation terms,
|
|
short sentences, decisions close with user impact. Power users who want the
|
|
tighter V0 prose set `gstack-config set explain_level terse` (binary switch,
|
|
no middle mode). See `docs/designs/PLAN_TUNING_V1.md` for the full design
|
|
rationale. The review pacing overhaul that originally tried to ride alongside
|
|
writing-style was extracted to V1.1 — see `docs/designs/PACING_UPDATES_V0.md`.
|
|
|
|
## Browser interaction
|
|
|
|
When you need to interact with a browser (QA, dogfooding, cookie setup), use the
|
|
`/browse` skill or run the browse binary directly via `$B <command>`. NEVER use
|
|
`mcp__claude-in-chrome__*` tools — they are slow, unreliable, and not what this
|
|
project uses.
|
|
|
|
**Sidebar architecture:** Before modifying `sidepanel.js`, `background.js`,
|
|
`content.js`, `terminal-agent.ts`, or sidebar-related server endpoints,
|
|
read `docs/designs/SIDEBAR_MESSAGE_FLOW.md`. The sidebar has one primary
|
|
surface — the **Terminal** pane (interactive `claude` PTY) — with
|
|
Activity / Refs / Inspector as debug overlays behind the footer's
|
|
`debug` toggle. The chat queue path was ripped once the PTY proved out;
|
|
`sidebar-agent.ts` and the `/sidebar-command` / `/sidebar-chat` /
|
|
`/sidebar-agent/event` endpoints are gone. The doc covers the WS auth
|
|
flow, dual-token model, and threat-model boundary — silent failures
|
|
here usually trace to not understanding the cross-component flow.
|
|
|
|
**Embedder terminal-agent ownership** (v1.42.1.0+, identity-based kill v1.44.0.0+).
|
|
`buildFetchHandler` in `browse/src/server.ts` accepts `ServerConfig.ownsTerminalAgent?:
|
|
boolean` (default `true`). When `true`, factory shutdown runs the full teardown:
|
|
identity-based kill via `killAgentByRecord(readAgentRecord(stateDir))` from
|
|
`browse/src/terminal-agent-control.ts` plus `safeUnlinkQuiet` on
|
|
`<stateDir>/terminal-port`, `<stateDir>/terminal-internal-token`, and
|
|
`<stateDir>/terminal-agent-pid` (the per-boot agent record introduced in v1.44).
|
|
Embedders (e.g. the gbrowser phoenix overlay) that pre-launch their own PTY
|
|
server must pass `false` so their discovery files survive gstack teardown cycles.
|
|
The flag is the third caller-owned teardown gate in `ServerConfig` (alongside
|
|
`xvfb?` and `proxyBridge?`); polarity is inverted (explicit bool vs presence) and
|
|
documented in the field's JSDoc. CLI `start()` always passes `true` explicitly —
|
|
the static-grep test in `browse/test/server-embedder-terminal-port.test.ts` fails
|
|
CI if a refactor drops it. Pre-v1.44 used `pkill -f terminal-agent\.ts` (regex
|
|
match) which would kill sibling gstack sessions on the same host; the new
|
|
`browse/test/terminal-agent-pid-identity.test.ts` static-grep tripwire fails CI
|
|
if any source file re-introduces `pkill ... terminal-agent` or `spawnSync('pkill', ...)`.
|
|
|
|
**WebSocket auth uses Sec-WebSocket-Protocol, not cookies.** Browsers
|
|
can't set `Authorization` on a WebSocket upgrade, but they CAN set
|
|
`Sec-WebSocket-Protocol` via `new WebSocket(url, [token])`. The agent
|
|
reads it, validates against `validTokens`, and MUST echo the protocol
|
|
back in the upgrade response — without the echo, Chromium closes the
|
|
connection immediately. `Set-Cookie: gstack_pty=...` is kept as a
|
|
fallback for non-browser callers (the cross-port `SameSite=Strict`
|
|
cookie path doesn't survive from a chrome-extension origin).
|
|
|
|
**Cross-pane PTY injection.** The toolbar's Cleanup button and the
|
|
Inspector's "Send to Code" action both pipe text into the live claude
|
|
PTY via `window.gstackInjectToTerminal(text)`, exposed by
|
|
`sidepanel-terminal.js`. No `/sidebar-command` POST — the live REPL is
|
|
the only execution surface in the sidebar now.
|
|
|
|
**`/health` MUST NOT surface any token — and it no longer does** (v1.63+).
|
|
The historical headed-mode leak of `AUTH_TOKEN` is fixed: `GET /health` is
|
|
liveness/status only in every mode. Token bootstrap is `POST /extension-token`,
|
|
which validates the caller's Origin against the pinned extension identity
|
|
(the `key` field in `extension/manifest.json` pins the extension ID —
|
|
`GSTACK_EXTENSION_ID` in `browse/src/server.ts`, derivation reproducible via
|
|
`bun browse/scripts/extension-id.ts`) plus a loopback Host. PTY auth still
|
|
flows through `POST /pty-session` only. Don't add any token to `/health`.
|
|
|
|
**Transport-layer security** (v1.6.0.0+). When `pair-agent` starts an ngrok tunnel,
|
|
the daemon binds two HTTP listeners: a local listener (127.0.0.1, full command
|
|
surface, never forwarded) and a tunnel listener (locked allowlist: `/connect`,
|
|
`/command` with a scoped token + 26-command browser-driving allowlist,
|
|
`/sidebar-chat`). ngrok forwards only the tunnel port. Root tokens over the tunnel
|
|
return 403. SSE endpoints use a 30-minute HttpOnly `gstack_sse` cookie minted via
|
|
`POST /sse-session` (never valid against `/command`). Tunnel-surface rejections go
|
|
to `~/.gstack/security/attempts.jsonl` via `tunnel-denial-log.ts`. Before editing
|
|
`server.ts`, `sse-session-cookie.ts`, or `tunnel-denial-log.ts`, read
|
|
[ARCHITECTURE.md](ARCHITECTURE.md#dual-listener-tunnel-architecture-v1600) —
|
|
the module boundary (no imports from `token-registry.ts` into `sse-session-cookie.ts`)
|
|
is load-bearing for scope isolation.
|
|
|
|
**Unicode sanitization at server egress** (v1.38.0.0+). Every server egress that
|
|
ships page-content-derived strings MUST go through `JSON.stringify(payload,
|
|
sanitizeReplacer)` for object payloads or `sanitizeLoneSurrogates(body)` for text
|
|
bodies. Lone UTF-16 surrogate halves from CDP page content otherwise reach the
|
|
Anthropic API as `\uD800`-style escapes and trigger a 400. Wired at four egress
|
|
points today: `handleCommandInternal` (HTTP + batch via a sanitizing wrapper around
|
|
`handleCommandInternalImpl`) and both SSE producers (`/activity/stream`,
|
|
`/inspector/events`). Post-stringify regex is a no-op — `JSON.stringify` has
|
|
already escaped the surrogate before regex could match, so the replacer must run
|
|
inside the encoding pipeline. Before adding a new SSE/WebSocket writer or HTTP
|
|
response in `server.ts`, read
|
|
[ARCHITECTURE.md](ARCHITECTURE.md#unicode-sanitization-at-server-egress-v13800).
|
|
`browse/test/server-sanitize-surrogates.test.ts` pins the wiring with invariant
|
|
tests, so bypasses fail CI.
|
|
|
|
**Egress receipts at every off-machine sink** (v1.63.0.0+). Every gstack-initiated
|
|
send off the machine MUST write a hash-chained receipt to
|
|
`~/.gstack/security/egress.jsonl` BEFORE the send: TypeScript callers use
|
|
`writeReceipt` from `lib/egress-receipt.ts`; shell scripts source
|
|
`bin/gstack-egress-lib.sh` and use `_receipted_curl` / `_receipted_git`. Failure
|
|
polarity is per-class: fail-closed for sensitive sinks (brain-sync, memory-ingest,
|
|
gbrain-sync, telemetry, ngrok tunnels, mcp-verify, supabase-provision), fail-open
|
|
+ stderr warning for user-facing ones (design OpenAI calls, update-check,
|
|
dashboards, git-class ops). The new-sink scanner in
|
|
`test/egress-receipt-wiring.test.ts` fails CI on an unreceipted `curl` /
|
|
`git push` / `fetch` to a non-loopback host unless the file carries a reasoned
|
|
entry in its `SCANNER_EXEMPT` list (user-directed page fetches, reachability
|
|
probes, instruction strings, skill prose) — if you add a new off-machine sink,
|
|
wire it through the helpers and add it to the enumerated sink list. Inspect with
|
|
`bin/gstack-egress` (`list` | `verify`, exit 3 on tamper | `grants`). Threat
|
|
model: forensic observability of ATTEMPTED egress, not an exfiltration control.
|
|
|
|
**SSE endpoint helper** (v1.51.0.0+). New SSE endpoints in `server.ts` MUST route
|
|
through `createSseEndpoint(req, config)` from `browse/src/sse-helpers.ts`. The
|
|
helper owns the cleanup contract (abort + enqueue-throw + heartbeat-throw, all
|
|
idempotent) and bakes in `sanitizeLoneSurrogates` on every JSON.stringify, so
|
|
new subscribers can't accidentally regress either invariant. Inline
|
|
`ReadableStream` wiring leaked subscribers when the TCP connection died without
|
|
firing `req.signal.abort` (Chromium MV3 service-worker suspend, intermediate
|
|
proxy half-close). `/activity/stream`, `/inspector/events`, and `/memory`
|
|
(SSE-eligible) all route through it. `browse/test/sse-helpers.test.ts` pins the
|
|
cleanup contract.
|
|
|
|
**CDP session lifecycle** (v1.51.0.0+). Direct `page.context().newCDPSession(page)`
|
|
calls outside `browse/src/cdp-bridge.ts` fail CI via the static-grep tripwire in
|
|
`browse/test/cdp-session-cleanup.test.ts`. Use `withCdpSession(page, async (s) => {...})`
|
|
for one-shot CDP work (try/finally detach) or `getOrCreateCdpSession(page, cache)`
|
|
for cached sessions tied to a page's lifetime (close-detach via `Map<page, session>`).
|
|
Three sites migrated: cdp-bridge frame events, write-commands archive capture,
|
|
cdp-inspector. The helpers prevent the per-session leak class where successful-path
|
|
detach happened but error-path detach was missed.
|
|
|
|
**Setup symlink hardening** (v1.38.0.0+). Every link site in `setup` MUST route
|
|
through the `_link_or_copy SRC DST` helper near the `IS_WINDOWS` detection. On
|
|
Windows without Developer Mode, plain `ln -snf` produces frozen file copies that
|
|
don't refresh on `git pull` — silent staleness across every host adapter. The
|
|
helper preserves `ln -snf` on Unix and switches to `cp -R` / `cp -f` on Windows.
|
|
`test/setup-windows-fallback.test.ts` enforces a static invariant: a single raw
|
|
`ln` call outside the helper body fails CI. Windows users get a one-line note
|
|
from `_print_windows_copy_note_once` reminding them to re-run `./setup` after
|
|
every `git pull`.
|
|
|
|
**Sidebar security stack** (layered defense against prompt injection):
|
|
|
|
| Layer | Module | Lives in |
|
|
|-------|--------|----------|
|
|
| L1-L3 | `content-security.ts` | server + read path — datamarking, hidden element strip, ARIA regex, URL blocklist, envelope wrapping |
|
|
| L4 | `security-classifier.ts` (TestSavantAI ONNX) | **security sidecar subprocess only** (`security-sidecar-entry.ts`, driven by `security-sidecar-client.ts` from server.ts) |
|
|
| Canary | `security.ts` (generate/inject/detect) | pure utilities — no production injector today (the chat prompt-builder that injected them was ripped) |
|
|
| Combiner | `security.ts` (combineVerdict + THRESHOLDS) | pure, tested; retains transcript/deberta vote handling for LayerSignal inputs no live layer produces anymore |
|
|
|
|
History note: an L4b Haiku transcript classifier and an opt-in DeBERTa ensemble
|
|
(`GSTACK_SECURITY_ENSEMBLE=deberta`) existed until the chat-path agent that
|
|
invoked them was ripped; both were deleted as dead code (zero production
|
|
callers). Do not re-document them as live.
|
|
|
|
**Critical constraint:** `security-classifier.ts` CANNOT be imported from the
|
|
compiled browse binary. `@huggingface/transformers` v4 requires `onnxruntime-node`
|
|
which fails to `dlopen` from Bun compile's temp extract dir — hence the sidecar
|
|
subprocess. Only `security.ts` (pure-string operations — canary utilities,
|
|
verdict combiner, status) is safe for `server.ts`. See
|
|
`~/.gstack/projects/garrytan-gstack/ceo-plans/2026-04-19-prompt-injection-guard.md`
|
|
§"Pre-Impl Gate 1 Outcome" for the original architectural decision.
|
|
|
|
**Thresholds** (in `security.ts`): `BLOCK: 0.85`, `WARN: 0.75`, `LOG_ONLY: 0.40`,
|
|
`SOLO_CONTENT_BLOCK: 0.92` (label-less content classifiers can't distinguish
|
|
"injection" from "phishing aimed at the user", so their solo bar is higher).
|
|
The live L4 path applies these in server.ts's sidecar-scan handling; canary
|
|
leak always BLOCKs (deterministic).
|
|
|
|
**Env knobs:**
|
|
- `GSTACK_SECURITY_OFF=1` — emergency kill switch. Classifier stays off even if
|
|
warmed; the L1-L3 filters keep running.
|
|
- Classifier model cache: `~/.gstack/models/testsavant-small/` (112MB, first run only)
|
|
- Attack log: `~/.gstack/security/attempts.jsonl` — written by
|
|
`tunnel-denial-log.ts` (tunnel-surface rejections; rotates at 10MB, 5 generations)
|
|
- Session state: `~/.gstack/security/session-state.json` (cross-process, atomic;
|
|
NOTE: classifierStatus currently has no live writer — shield status derives
|
|
from what's on disk)
|
|
|
|
## Dev symlink awareness
|
|
|
|
When developing gstack, `.claude/skills/gstack` may be a symlink back to this
|
|
working directory (gitignored). This means skill changes are **live immediately**,
|
|
great for rapid iteration, risky during big refactors where half-written skills
|
|
could break other Claude Code sessions using gstack concurrently.
|
|
|
|
**Check once per session:** Run `ls -la .claude/skills/gstack` to see if it's a
|
|
symlink or a real copy. If it's a symlink to your working directory, be aware that:
|
|
- Template changes + `bun run gen:skill-docs` immediately affect all gstack invocations
|
|
- Breaking changes to SKILL.md.tmpl files can break concurrent gstack sessions
|
|
- During large refactors, remove the symlink (`rm .claude/skills/gstack`) so the
|
|
global install at `~/.claude/skills/gstack/` is used instead
|
|
|
|
**Prefix setting:** Setup creates real directories (not symlinks) at the top level
|
|
with a SKILL.md symlink inside (e.g., `qa/SKILL.md -> gstack/qa/SKILL.md`). This
|
|
ensures Claude discovers them as top-level skills, not nested under `gstack/`.
|
|
Names are either short (`qa`) or namespaced (`gstack-qa`), controlled by
|
|
`skill_prefix` in `~/.gstack/config.yaml`. Pass `--no-prefix` or `--prefix` to
|
|
skip the interactive prompt.
|
|
|
|
**Note:** Vendoring gstack into a project's repo is deprecated. Use global install
|
|
+ `./setup --team` instead. See README.md for team mode instructions.
|
|
|
|
**For plan reviews:** When reviewing plans that modify skill templates or the
|
|
gen-skill-docs pipeline, consider whether the changes should be tested in isolation
|
|
before going live (especially if the user is actively using gstack in other windows).
|
|
|
|
**Upgrade migrations:** When a change modifies on-disk state (directory structure,
|
|
config format, stale files) in ways that could break existing user installs, add a
|
|
migration script to `gstack-upgrade/migrations/`. Read CONTRIBUTING.md's "Upgrade
|
|
migrations" section for the format and testing requirements. The upgrade skill runs
|
|
these automatically after `./setup` during `/gstack-upgrade`.
|
|
|
|
## Compiled binaries — never commit browse/dist/, design/dist/, or make-pdf/dist/
|
|
|
|
The `browse/dist/`, `design/dist/`, and `make-pdf/dist/` directories contain
|
|
compiled Bun binaries (`browse`, `find-browse`, `design`, ~62MB each). These are
|
|
Mach-O arm64 only — they do NOT work on Linux, Windows, or Intel Macs. The
|
|
`./setup` script builds from source for every platform.
|
|
|
|
These directories are **untracked and gitignored** (`.gitignore:3-6`; the
|
|
`browse/dist/` binaries were untracked in `64d5a3e4`, v0.11.16.0; the others were
|
|
never tracked). They will NOT appear in `git status`. If a dist binary ever does
|
|
show up in `git status`, something force-added it (`git add -f`) — do not commit
|
|
it; unstage it and find out how it got there.
|
|
|
|
When staging files, always use specific filenames (`git add file1 file2`) — never
|
|
`git add .` or `git add -A`, which can sweep in build outputs and junk.
|
|
|
|
## Redaction guard (PII / secrets / legal content)
|
|
|
|
Shared redaction engine catches credentials, PII, and legal/damaging content
|
|
before it reaches an external sink (codex dispatch, GitHub issue/PR body, pushed
|
|
commit). It is a **guardrail, not airtight enforcement** — `git push --no-verify`,
|
|
direct `gh issue create`, and `GSTACK_REDACT_PREPUSH=skip` all bypass it. It
|
|
catches accidents and carelessness, the 99% case. Do not claim it stops a
|
|
determined leaker (a CHANGELOG line that does would fail a hostile screenshotter).
|
|
|
|
- **Engine + taxonomy:** `lib/redact-patterns.ts` (the single source of truth —
|
|
3 tiers; HIGH = genuinely-secret credentials that block, MEDIUM = PII/legal/
|
|
internal + high-FP credential shapes that confirm via AskUserQuestion, LOW =
|
|
FYI) and `lib/redact-engine.ts` (pure `scan()` + `applyRedactions()`).
|
|
Calibration matters: a gate that cries wolf gets ignored, so context-variable
|
|
shapes (Stripe `pk_live_`, Google `AIza`, JWT, env `*_KEY=`) sit at MEDIUM.
|
|
- **CLI:** `bin/gstack-redact` (exit 0 clean / 2 MEDIUM / 3 HIGH; `--json`,
|
|
`--auto-redact`, `--repo-visibility`, `--from-file`). `bin/gstack-redact-prepush`
|
|
is the opt-in git hook.
|
|
- **Skill docs are generated** from `scripts/resolvers/redact-doc.ts`
|
|
(`{{REDACT_INVOCATION_BLOCK:<sink>}}`) so /spec,
|
|
/cso, /ship, /document-release, /document-generate never drift from the engine.
|
|
- **Scan-at-sink:** always scan the EXACT bytes that will be sent — write to a
|
|
temp file, scan that file, pass the SAME file to `gh`/`git`. Never scan a string
|
|
then re-render (that reopens a scan-vs-send gap).
|
|
- **Visibility (no tier promotion):** resolve once per run, order = local config
|
|
(`gstack-config get redact_repo_visibility`, ~/.gstack so never committed) → gh
|
|
→ glab → unknown(=public-strict). Public repos get STERNER per-finding
|
|
confirmation (no batch-acknowledge, no silent-proceed); MEDIUM is never
|
|
auto-promoted to HIGH.
|
|
- **Tool-attributed fences:** wrap Codex/Greptile/eval output in ` ```codex-review `
|
|
/ ` ```greptile ` fences so example credentials those tools quote WARN-degrade
|
|
instead of blocking. A live-format credential inside the fence still blocks.
|
|
- **Config keys:** `redact_repo_visibility` (public|private|unknown, local-only
|
|
override for repos gh/glab can't read), `redact_prepush_hook` (true|false).
|
|
There is intentionally NO key to disable HIGH blocking.
|
|
- **Audit:** the /spec semantic pass appends a content-free record (categories +
|
|
body sha256, no spec text) to `~/.gstack/security/semantic-reviews.jsonl` (0600).
|
|
|
|
## Commit style
|
|
|
|
**Always bisect commits.** Every commit should be a single logical change. When
|
|
you've made multiple changes (e.g., a rename + a rewrite + new tests), split them
|
|
into separate commits before pushing. Each commit should be independently
|
|
understandable and revertable.
|
|
|
|
Examples of good bisection:
|
|
- Rename/move separate from behavior changes
|
|
- Test infrastructure (touchfiles, helpers) separate from test implementations
|
|
- Template changes separate from generated file regeneration
|
|
- Mechanical refactors separate from new features
|
|
|
|
When the user says "bisect commit" or "bisect and push," split staged/unstaged
|
|
changes into logical commits and push.
|
|
|
|
## Slop-scan: AI code quality, not AI code hiding
|
|
|
|
We use [slop-scan](https://github.com/benvinegar/slop-scan) to catch patterns where
|
|
AI-generated code is genuinely worse than what a human would write. We are NOT trying
|
|
to pass as human code. We are AI-coded and proud of it. The goal is code quality.
|
|
|
|
```bash
|
|
npx slop-scan scan . # human-readable report
|
|
npx slop-scan scan . --json # machine-readable for diffing
|
|
```
|
|
|
|
Config: `slop-scan.config.json` at repo root (currently excludes `**/vendor/**`).
|
|
|
|
### What to fix (genuine quality improvements)
|
|
|
|
- **Empty catches around file ops** — use `safeUnlink()` (ignores ENOENT, rethrows
|
|
EPERM/EIO). A swallowed EPERM in cleanup means silent data loss.
|
|
- **Empty catches around process kills** — use `safeKill()` (ignores ESRCH, rethrows
|
|
EPERM). A swallowed EPERM means you think you killed something you didn't.
|
|
- **Redundant `return await`** — remove when there's no enclosing try block. Saves a
|
|
microtask, signals intent.
|
|
- **Typed exception catches** — `catch (err) { if (!(err instanceof TypeError)) throw err }`
|
|
is genuinely better than `catch {}` when the try block does URL parsing or DOM work.
|
|
You know what error you expect, so say so.
|
|
|
|
### What NOT to fix (linter gaming, not quality)
|
|
|
|
- **String-matching on error messages** — `err.message.includes('closed')` is brittle.
|
|
Playwright/Chrome can change wording anytime. If a fire-and-forget operation can fail
|
|
for ANY reason and you don't care, `catch {}` is the correct pattern.
|
|
- **Adding comments to exempt pass-through wrappers** — "alias for active session" above
|
|
a method just to trip slop-scan's exemption rule is noise, not documentation.
|
|
- **Converting extension catch-and-log to selective rethrow** — Chrome extensions crash
|
|
entirely on uncaught errors. If the catch logs and continues, that IS the right pattern
|
|
for extension code. Don't make it throw.
|
|
- **Tightening best-effort cleanup paths** — shutdown, emergency cleanup, and disconnect
|
|
code should use `safeUnlinkQuiet()` (swallows ALL errors). A cleanup path that throws
|
|
on EPERM means the rest of cleanup doesn't run. That's worse.
|
|
|
|
### Utilities in `browse/src/error-handling.ts`
|
|
|
|
| Function | Use when | Behavior |
|
|
|----------|----------|----------|
|
|
| `safeUnlink(path)` | Normal file deletion | Ignores ENOENT, rethrows others |
|
|
| `safeUnlinkQuiet(path)` | Shutdown/emergency cleanup | Swallows all errors |
|
|
| `safeKill(pid, signal)` | Sending signals | Ignores ESRCH, rethrows others |
|
|
| `isProcessAlive(pid)` | Boolean process checks | Returns true/false, never throws |
|
|
|
|
### Score tracking
|
|
|
|
Baseline (2026-04-09, before cleanup): 100 findings, 432.8 score, 2.38 score/file.
|
|
After cleanup: 90 findings, 358.1 score, 1.96 score/file.
|
|
|
|
Don't chase the number. Fix patterns that represent actual code quality problems.
|
|
Accept findings where the "sloppy" pattern is the correct engineering choice.
|
|
|
|
## Community PR guardrails
|
|
|
|
When reviewing or merging community PRs, **always AskUserQuestion** before accepting
|
|
any commit that:
|
|
|
|
1. **Touches ETHOS.md** — this file is Garry's personal builder philosophy. No edits
|
|
from external contributors or AI agents, period.
|
|
2. **Removes or softens promotional material** — YC references, founder perspective,
|
|
and product voice are intentional. PRs that frame these as "unnecessary" or
|
|
"too promotional" must be rejected.
|
|
3. **Changes Garry's voice** — the tone, humor, directness, and perspective in skill
|
|
templates, CHANGELOG, and docs are not generic. PRs that rewrite voice to be
|
|
more "neutral" or "professional" must be rejected.
|
|
|
|
Even if the agent strongly believes a change improves the project, these three
|
|
categories require explicit user approval via AskUserQuestion. No exceptions.
|
|
No auto-merging. No "I'll just clean this up."
|
|
|
|
## Checking out PRs from garrytan-agents
|
|
|
|
When the user says "check out <PR link>" and the PR is from `garrytan-agents/gstack`
|
|
(or any other fork that is NOT a collaborator on `garrytan/gstack`), do NOT just
|
|
`gh pr checkout`. Fork PRs don't receive base-repo secrets (`ANTHROPIC_API_KEY`,
|
|
`OPENAI_API_KEY`, etc.), so the eval/E2E CI jobs fail with empty-env auth errors
|
|
regardless of what's set on the base repo.
|
|
|
|
**Workflow:** push the branch to `garrytan/gstack` (the base repo) and re-target
|
|
the PR from there.
|
|
|
|
Concretely, after `gh pr checkout <N>`:
|
|
|
|
1. Note the original PR number and head branch name.
|
|
2. Push the same branch to the base repo: `git push origin HEAD:<branch-name>`
|
|
(origin = `garrytan/gstack`, since the worktree is set up with that remote).
|
|
3. Close the fork PR (`gh pr close <N> --comment "moving to base-repo branch for secret access"`).
|
|
4. Open a new PR from the base-repo branch: `gh pr create --base main --head <branch-name>`.
|
|
5. New PR's workflows will get secrets automatically.
|
|
|
|
Why not fix it on the fork side? `garrytan-agents` isn't a collaborator on
|
|
`garrytan/gstack`. Adding it as a collaborator (option A) or flipping the
|
|
repo-wide "send secrets to fork PRs" toggle (option B) would let secrets reach
|
|
fork PRs from anyone — broader blast radius than just moving this one branch.
|
|
Option C (this section) keeps secret-distribution scope tight.
|
|
|
|
If the user asks you to skip the move (e.g., "just leave it as a fork PR"),
|
|
respect that — eval CI will fail with empty-env auth, but check-freshness,
|
|
workflow-lint, and windows-tests will still pass on the fork PR.
|
|
|
|
## CHANGELOG + VERSION style
|
|
|
|
**Versioning invariant (workspace-aware ship).** VERSION is a monotonic ordered
|
|
release identifier, not a strict semver commitment. The bump level
|
|
(major/minor/patch/micro) expresses intent at ship time. Queue-advancing past a
|
|
claimed version within the same bump level is explicitly permitted — if branch A
|
|
claims v1.7.0.0 as a MINOR and branch B is also a MINOR, B lands at v1.8.0.0
|
|
(still a MINOR relative to main). Downstream consumers must NOT rely on
|
|
"MINOR = feature-only, PATCH = fix-only" as a strict contract. This is why
|
|
`bin/gstack-next-version` advances within the chosen bump level rather than
|
|
repicking the level when collisions happen.
|
|
|
|
**Scale-aware bumps — use common sense.** When the diff is big, bump MINOR (or
|
|
MAJOR), not PATCH. PATCH is for bug fixes and small additions; MINOR is for
|
|
substantial new capability or substantial reduction; MAJOR is for breaking
|
|
changes. Rough guideposts (don't treat as rules, treat as smell-checks):
|
|
|
|
- **PATCH (X.Y.Z+1.0)**: bug fix, doc tweak, small additive change, single
|
|
test/file added. Net diff under ~500 lines, no new user-facing capability.
|
|
- **MINOR (X.Y+1.0.0)**: new capability shipped (skill, harness, command, big
|
|
refactor), substantial code reduction (compression, migration), or coordinated
|
|
multi-file change. Net diff over ~2000 lines added/removed, OR a user-visible
|
|
feature you'd put in a tweet.
|
|
- **MAJOR (X+1.0.0.0)**: breaking change to public surface (CLI flag rename,
|
|
skill removed, config format changed), OR a release big enough to be the
|
|
headline of a blog post.
|
|
|
|
If you find yourself debating "is 10K added + 24K removed really a PATCH?" — it
|
|
isn't. Bump MINOR. Same for "this adds a whole new test harness with 6 new E2E
|
|
tests + helper utilities" — MINOR. The bump level is communication to the user
|
|
about what kind of release this is; don't undersell it.
|
|
|
|
When merging origin/main brings a higher VERSION, re-evaluate the bump level
|
|
against the SCALE of your branch's work, not just whether main moved forward.
|
|
If main bumped MINOR and your branch is also a substantial change, you bump
|
|
MINOR again on top (e.g., main at v1.14.0.0, your branch lands v1.15.0.0).
|
|
|
|
**VERSION and CHANGELOG are branch-scoped.** Every feature branch that ships gets its
|
|
own version bump and CHANGELOG entry. The entry describes what THIS branch adds —
|
|
not what was already on main.
|
|
|
|
**The CHANGELOG entry is the diff between main and the shipping branch — what users
|
|
get when they upgrade. NOT how the branch got there.** A reader landing on the entry
|
|
should learn what they can do now that they couldn't before; they should not learn
|
|
about the branch's internal version bumps, the bugs we caught and fixed mid-branch,
|
|
the plan reviews we ran, or the commits we squashed. That is branch development
|
|
narrative. It belongs in PR descriptions and commit messages, not CHANGELOG.
|
|
|
|
**Never reference branch-internal versions in a CHANGELOG entry.** If your branch
|
|
bumped VERSION from v1.5.0.0 → v1.5.1.0 → v1.6.0.0 during development and only the
|
|
final v1.6.0.0 ships to main, the entry must read as if v1.5.1.0 never existed.
|
|
Concretely, NEVER write:
|
|
- "v1.5.1.0 had a bug that v1.6.0.0 fixes" — readers don't know about v1.5.1.0; it's
|
|
a branch-internal artifact.
|
|
- "The shipping headline of v1.5.1.0 was broken because..." — same reason. From main's
|
|
perspective, v1.5.1.0 was never released.
|
|
- "Pre-fix tests encoded the broken behavior" — that's a contributor's victory lap,
|
|
not a user benefit.
|
|
- "Two surgical edits, both in the dispatch path" — micro-narrative of the patch.
|
|
|
|
Instead, describe the released system: "Browser-skills run end-to-end with the
|
|
expected tab-access semantics." If a property of the shipped system is worth calling
|
|
out (e.g., "skill spawns get permissive tab access; pair-agent tunnel tokens require
|
|
ownership"), document it as a property, not as a fix. The shipped system is what
|
|
the user gets; the path to that system is invisible to them.
|
|
|
|
**When to write the CHANGELOG entry:**
|
|
- At `/ship` time (Step 13), not during development or mid-branch.
|
|
- The entry covers ALL commits on this branch vs the base branch.
|
|
- Never fold new work into an existing CHANGELOG entry from a prior version that
|
|
already landed on main. If main has v0.10.0.0 and your branch adds features,
|
|
bump to v0.10.1.0 with a new entry — don't edit the v0.10.0.0 entry.
|
|
|
|
**Key questions before writing:**
|
|
1. What branch am I on? What did THIS branch change?
|
|
2. Is the base branch version already released? (If yes, bump and create new entry.)
|
|
3. Does an existing entry on this branch already cover earlier work? (If yes, replace
|
|
it with one unified entry for the final version.)
|
|
|
|
**Merging main does NOT mean adopting main's version.** When you merge origin/main into
|
|
a feature branch, main may bring new CHANGELOG entries and a higher VERSION. Your branch
|
|
still needs its OWN version bump on top. If main is at v0.13.8.0 and your branch adds
|
|
features, bump to v0.13.9.0 with a new entry. Never jam your changes into an entry that
|
|
already landed on main. Your entry goes on top because your branch lands next.
|
|
|
|
**After merging main, always check:**
|
|
- Does CHANGELOG have your branch's own entry separate from main's entries?
|
|
- Is VERSION higher than main's VERSION?
|
|
- Is your entry the topmost entry in CHANGELOG (above main's latest)?
|
|
If any answer is no, fix it before continuing.
|
|
|
|
**After any CHANGELOG edit that moves, adds, or removes entries,** immediately run
|
|
`grep "^## \[" CHANGELOG.md` to verify no duplicates and a sensible reverse-chronological
|
|
order. Gaps between version numbers are fine. A branch that ships at v1.6.4.0 without
|
|
a prior v1.5.2.0 or v1.5.3.0 entry on main is correct — those were branch-internal
|
|
version numbers that never landed. Do not back-fill gaps with placeholder entries.
|
|
|
|
**Never orphan branch-internal versions.** If your branch bumped VERSION several times
|
|
during development (v1.5.1.0 → v1.5.2.0 → v1.6.4.0, say) and those earlier entries were
|
|
never released to main, the final ship consolidates ALL of them into a single entry at
|
|
the final version (v1.6.4.0). Collapse them — delete the old entries and move their
|
|
content into the final entry, re-version table columns accordingly. Readers see one
|
|
release, not a branch diary. Gaps are fine (v1.6.3.0 → v1.6.4.0 with no v1.5.x
|
|
in between on main is correct).
|
|
|
|
CHANGELOG.md is **for users**, not contributors. Write it like product release notes:
|
|
|
|
- Lead with what the user can now **do** that they couldn't before. Sell the feature.
|
|
- Use plain language, not implementation details. "You can now..." not "Refactored the..."
|
|
- **Never mention TODOS.md, internal tracking, eval infrastructure, or contributor-facing
|
|
details.** These are invisible to users and meaningless to them.
|
|
- Put contributor/internal changes in a separate "For contributors" section at the bottom.
|
|
- Every entry should make someone think "oh nice, I want to try that."
|
|
- No jargon: say "every question now tells you which project and branch you're in" not
|
|
"AskUserQuestion format standardized across skill templates via preamble resolver."
|
|
|
|
**Only document what shipped between main and this change.** Readers do not care how
|
|
we got here. Keep out of the CHANGELOG, always:
|
|
|
|
- Branch resyncs, merge commits with main, rebase activity.
|
|
- Plan approvals, review outcomes (CEO / eng / design / outside-voice / codex findings),
|
|
AskUserQuestion decisions, scope negotiations.
|
|
- "Work queued," "plan approved," "in-progress," "will ship later" — the CHANGELOG
|
|
documents what DID ship, not what MIGHT ship.
|
|
- Version-bump housekeeping when no user-facing work actually landed.
|
|
|
|
If the diff between the base branch version and this version has no user-facing change
|
|
(only merges, only CHANGELOG edits, only placeholder work), the honest entry is one
|
|
sentence: "Version bump for branch-ahead discipline. No user-facing changes yet." Stop
|
|
there. Do not pad. Do not explain the plan that will ship eventually. Do not narrate
|
|
the branch's history. When real work lands, the entry will replace this at /ship time.
|
|
|
|
### Release-summary format (every `## [X.Y.Z]` entry)
|
|
|
|
Every version entry in `CHANGELOG.md` MUST start with a release-summary section in
|
|
the GStack/Garry voice, one viewport's worth of prose + tables that lands like a
|
|
verdict, not marketing. The itemized changelog (subsections, bullets, files) goes
|
|
BELOW that summary, separated by a `### Itemized changes` header.
|
|
|
|
The release-summary section gets read by humans, by the auto-update agent, and by
|
|
anyone deciding whether to upgrade. The itemized list is for agents that need to
|
|
know exactly what changed.
|
|
|
|
Structure for the top of every `## [X.Y.Z]` entry:
|
|
|
|
1. **Two-line bold headline** (10-14 words total). Should land like a verdict, not
|
|
marketing. Sound like someone who shipped today and cares whether it works.
|
|
2. **Lead paragraph** (3-5 sentences). What shipped, what changed for the user.
|
|
Specific, concrete, no AI vocabulary, no em dashes, no hype.
|
|
3. **A "The X numbers that matter" section** with:
|
|
- One short setup paragraph naming the source of the numbers (real production
|
|
deployment OR a reproducible benchmark, name the file/command to run).
|
|
- A table of 3-6 key metrics with BEFORE / AFTER / Δ columns.
|
|
- A second optional table for per-category breakdown if relevant.
|
|
- 1-2 sentences interpreting the most striking number in concrete user terms.
|
|
4. **A "What this means for [audience]" closing paragraph** (2-4 sentences) tying
|
|
the metrics to a real workflow shift. End with what to do.
|
|
|
|
Voice rules for the release summary:
|
|
- No em dashes (use commas, periods, "...").
|
|
- No AI vocabulary (delve, robust, comprehensive, nuanced, fundamental, etc.) or
|
|
banned phrases ("here's the kicker", "the bottom line", etc.).
|
|
- Real numbers, real file names, real commands. Not "fast" but "~30s on 30K pages."
|
|
- Short paragraphs, mix one-sentence punches with 2-3 sentence runs.
|
|
- Connect to user outcomes: "the agent does ~3x less reading" beats "improved precision."
|
|
- Be direct about quality. "Well-designed" or "this is a mess." No dancing.
|
|
|
|
Source material:
|
|
- CHANGELOG previous entry for prior context.
|
|
- Benchmark files or `/retro` output for headline numbers.
|
|
- Recent commits (`git log <prev-version>..HEAD --oneline`) for what shipped.
|
|
- Don't make up numbers. If a metric isn't in a benchmark or production data,
|
|
don't include it. Say "no measurement yet" if asked.
|
|
|
|
Target length: ~250-350 words for the summary. Should render as one viewport.
|
|
|
|
### Itemized changes (below the release summary)
|
|
|
|
Write `### Itemized changes` and continue with the detailed subsections (Added,
|
|
Changed, Fixed, For contributors). Same rules as the user-facing voice guidance
|
|
above, plus:
|
|
|
|
- **Always credit community contributions.** When an entry includes work from a
|
|
community PR, name the contributor with `Contributed by @username`. Contributors
|
|
did real work. Thank them publicly every time, no exceptions.
|
|
|
|
## AI effort compression
|
|
|
|
When estimating or discussing effort, always show both human-team and CC+gstack time:
|
|
|
|
| Task type | Human team | CC+gstack | Compression |
|
|
|-----------|-----------|-----------|-------------|
|
|
| Boilerplate / scaffolding | 2 days | 15 min | ~100x |
|
|
| Test writing | 1 day | 15 min | ~50x |
|
|
| Feature implementation | 1 week | 30 min | ~30x |
|
|
| Bug fix + regression test | 4 hours | 15 min | ~20x |
|
|
| Architecture / design | 2 days | 4 hours | ~5x |
|
|
| Research / exploration | 1 day | 3 hours | ~3x |
|
|
|
|
Completeness is cheap. Don't recommend shortcuts when the complete implementation
|
|
is achievable. Boil the ocean — the complete thing is the goal; only genuinely
|
|
unrelated multi-quarter migrations are separate scope, never an excuse for a
|
|
shortcut. See the Completeness Principle in the skill preamble for the full
|
|
philosophy.
|
|
|
|
## Search before building
|
|
|
|
Before designing any solution that involves concurrency, unfamiliar patterns,
|
|
infrastructure, or anything where the runtime/framework might have a built-in:
|
|
|
|
1. Search for "{runtime} {thing} built-in"
|
|
2. Search for "{thing} best practice {current year}"
|
|
3. Check official runtime/framework docs
|
|
|
|
Three layers of knowledge: tried-and-true (Layer 1), new-and-popular (Layer 2),
|
|
first-principles (Layer 3). Prize Layer 3 above all. See ETHOS.md for the full
|
|
builder philosophy.
|
|
|
|
## Local plans
|
|
|
|
Contributors can store long-range vision docs and design documents in `~/.gstack-dev/plans/`.
|
|
These are local-only (not checked in). When reviewing TODOS.md, check `plans/` for candidates
|
|
that may be ready to promote to TODOs or implement.
|
|
|
|
## E2E eval failure blame protocol
|
|
|
|
When an E2E eval fails during `/ship` or any other workflow, **never claim "not
|
|
related to our changes" without proving it.** These systems have invisible couplings —
|
|
a preamble text change affects agent behavior, a new helper changes timing, a
|
|
regenerated SKILL.md shifts prompt context.
|
|
|
|
**Required before attributing a failure to "pre-existing":**
|
|
1. Run the same eval on main (or base branch) and show it fails there too
|
|
2. If it passes on main but fails on the branch — it IS your change. Trace the blame.
|
|
3. If you can't run on main, say "unverified — may or may not be related" and flag it
|
|
as a risk in the PR body
|
|
|
|
"Pre-existing" without receipts is a lazy claim. Prove it or don't say it.
|
|
|
|
## Long-running tasks: don't give up
|
|
|
|
When running evals, E2E tests, or any long-running background task, **poll until
|
|
completion**. Use `sleep 180 && echo "ready"` + `TaskOutput` in a loop every 3
|
|
minutes. Never switch to blocking mode and give up when the poll times out. Never
|
|
say "I'll be notified when it completes" and stop checking — keep the loop going
|
|
until the task finishes or the user tells you to stop.
|
|
|
|
The full E2E suite can take 30-45 minutes. That's 10-15 polling cycles. Do all of
|
|
them. Report progress at each check (which tests passed, which are running, any
|
|
failures so far). The user wants to see the run complete, not a promise that
|
|
you'll check later.
|
|
|
|
## Running evals as an agent: always detach (SIGTERM-proof)
|
|
|
|
When **you (an agent/harness)** launch a long eval/benchmark run, run it through
|
|
`bin/gstack-detach` — NEVER as a plain backgrounded Bash task. A plain background
|
|
task lives in the harness's process group, so a SIGTERM ("polite quit") on a turn
|
|
boundary, a stopped Monitor, or an interruption kills the run mid-flight (observed:
|
|
`script "test:gate" was terminated by signal SIGTERM` ~40 min into a run). On macOS
|
|
the run can also die to idle-sleep. `gstack-detach` fixes both: a fresh session
|
|
(escapes the group SIGTERM) wrapped in `caffeinate -i` (blocks idle-sleep).
|
|
|
|
- Use the `eval:bg*` scripts (`eval:bg`, `eval:bg:all`, `eval:bg:gate`,
|
|
`eval:bg:periodic`) — they wrap the eval command in `gstack-detach` with the
|
|
machine-wide `gstack-evals` lock (concurrent worktrees serialize instead of
|
|
saturating the shared model API), a per-tier watchdog, and a **run-scoped** log
|
|
under `~/.gstack-dev/eval-runs/` (no shared-`/tmp` collision). Each prints its
|
|
log path. `eval:bg:gate` / `eval:bg:periodic` run their tier through the
|
|
sharded paid runner (`scripts/test-paid-shards.ts`, also exposed as
|
|
`test:gate:sharded` / `test:periodic:sharded`): one Bun process per test
|
|
file, an external wall-clock timeout that kills the shard's process GROUP
|
|
(stray `claude`/`codex` grandchildren included), a per-shard
|
|
`GSTACK_EVAL_DIR=<evalDir>/shards/<slug>/` honored by the `EvalCollector`
|
|
constructor, and an aggregate that separates failed vs timed-out vs
|
|
never-started shards — the detach timeouts (25200s gate / 28800s periodic)
|
|
are sized against worst-case shard wall clock. `eval:list` / `eval:compare` /
|
|
`eval:summary` read the shard dirs too. Or call
|
|
`gstack-detach [--lock NAME] [--timeout SECS] [--label LBL] --
|
|
<cmd>` directly for any long agent job. Export `ANTHROPIC_API_KEY` first (never
|
|
pass keys in argv).
|
|
- Then **poll the printed logfile** with a death-aware watcher: break on the
|
|
guaranteed `### gstack-detach EXIT=<code> ###` sentinel (success AND failure are
|
|
both marked, so silence is never mistaken for success). The detached run survives
|
|
even if your watcher gets reaped, so re-checking the log always works.
|
|
- Why the lock: a shared dev box with several Conductor worktrees will rate-limit
|
|
the model API if two eval suites run at once (15-way concurrency each), which
|
|
mass-times-out E2E tests. The lock makes the second run WAIT, not collide.
|
|
- Humans running `bun run test:evals` foreground in their own terminal don't need
|
|
this — Ctrl-C is intended there. Detachment is for agent-launched runs only.
|
|
|
|
## E2E test fixtures: extract, don't copy
|
|
|
|
**NEVER copy a full SKILL.md file into an E2E test fixture.** SKILL.md files are
|
|
1500-2000 lines. When `claude -p` reads a file that large, context bloat causes
|
|
timeouts, flaky turn limits, and tests that take 5-10x longer than necessary.
|
|
|
|
Instead, extract only the section the test actually needs:
|
|
|
|
```typescript
|
|
// BAD — agent reads 1900 lines, burns tokens on irrelevant sections
|
|
fs.copyFileSync(path.join(ROOT, 'ship', 'SKILL.md'), path.join(dir, 'ship-SKILL.md'));
|
|
|
|
// GOOD — agent reads ~60 lines, finishes in 38s instead of timing out
|
|
const full = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
|
const start = full.indexOf('## Review Readiness Dashboard');
|
|
const end = full.indexOf('\n---\n', start);
|
|
fs.writeFileSync(path.join(dir, 'ship-SKILL.md'), full.slice(start, end > start ? end : undefined));
|
|
```
|
|
|
|
Also when running targeted E2E tests to debug failures:
|
|
- Run in **foreground** (`bun test ...`), not background with `&` and `tee`
|
|
- Never `pkill` running eval processes and restart — you lose results and waste money
|
|
- One clean run beats three killed-and-restarted runs
|
|
|
|
## Publishing native OpenClaw skills to ClawHub
|
|
|
|
Native OpenClaw skills live in `openclaw/skills/gstack-openclaw-*/SKILL.md`. These are
|
|
hand-crafted methodology skills (not generated by the pipeline) published to ClawHub
|
|
so any OpenClaw user can install them.
|
|
|
|
**Publishing:** The command is `clawhub publish` (NOT `clawhub skill publish`):
|
|
|
|
```bash
|
|
clawhub publish openclaw/skills/gstack-openclaw-office-hours \
|
|
--slug gstack-openclaw-office-hours --name "gstack Office Hours" \
|
|
--version 1.0.0 --changelog "description of changes"
|
|
```
|
|
|
|
Repeat for each skill: `gstack-openclaw-ceo-review`, `gstack-openclaw-investigate`,
|
|
`gstack-openclaw-retro`. Bump `--version` on each update.
|
|
|
|
**Auth:** `clawhub login` (opens browser for GitHub auth). `clawhub whoami` to verify.
|
|
|
|
**Updating:** Same `clawhub publish` command with a higher `--version` and `--changelog`.
|
|
|
|
**Verification:** `clawhub search gstack` to confirm they're live.
|
|
|
|
## Deploying to the active skill
|
|
|
|
The active skill lives at `~/.claude/skills/gstack/`. After making changes:
|
|
|
|
1. Push your branch
|
|
2. Fetch and reset in the skill directory: `cd ~/.claude/skills/gstack && git fetch origin && git reset --hard origin/main`
|
|
3. Rebuild: `cd ~/.claude/skills/gstack && bun run build`
|
|
|
|
**If you use gbrain:** the `git reset --hard` in step 2 reverts the brain-aware
|
|
(`GBRAIN_CONTEXT_LOAD` / `GBRAIN_SAVE_RESULTS`) blocks that `gstack-config
|
|
gbrain-refresh` renders into the install (those generated blocks differ from
|
|
`main` by design). After deploying, re-run `gstack-config gbrain-refresh` to
|
|
restore them across all your projects' Claude sessions. It's idempotent.
|
|
|
|
Or copy the binaries directly:
|
|
- `cp browse/dist/browse ~/.claude/skills/gstack/browse/dist/browse`
|
|
- `cp design/dist/design ~/.claude/skills/gstack/design/dist/design`
|
|
|
|
## Skill routing
|
|
|
|
When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
|
|
|
|
Key routing rules:
|
|
- Product ideas/brainstorming → invoke /office-hours
|
|
- Strategy/scope → invoke /plan-ceo-review
|
|
- Architecture → invoke /plan-eng-review
|
|
- Design system/plan review → invoke /design-consultation or /plan-design-review
|
|
- Full review pipeline → invoke /autoplan
|
|
- Bugs/errors → invoke /investigate
|
|
- QA/testing site behavior → invoke /qa or /qa-only
|
|
- Code review/diff check → invoke /review
|
|
- Visual polish → invoke /design-review
|
|
- Ship/deploy/PR → invoke /ship or /land-and-deploy
|
|
- Save progress → invoke /context-save
|
|
- Resume context → invoke /context-restore
|
|
|
|
## Cross-session decision memory
|
|
|
|
Durable decisions and their rationale are captured in an append-only, event-sourced
|
|
store at `~/.gstack/projects/<slug>/decisions.jsonl` so neither you nor the user
|
|
re-litigates a settled call or loses the "why" across sessions. This is the reliable,
|
|
file-only path: it works with gbrain OFF. (gbrain semantic recall is an optional
|
|
enhancement layered on top, never a dependency.)
|
|
|
|
- **Resurface** active decisions before re-deciding: `bin/gstack-decision-search`
|
|
(`--recent N`, `--scope repo|branch|issue`, `--query KW`, `--all`, `--json`).
|
|
Add `--semantic` (with `--query`) to append related hits from gbrain memory when
|
|
it's up; it degrades silently to the reliable file results when gbrain is off.
|
|
Session start already surfaces scope-relevant active decisions via Context Recovery.
|
|
If a decision is listed, treat it as settled with its rationale; if you're about to
|
|
reverse it, say so explicitly.
|
|
- **Capture** a DURABLE decision when you or the user make one:
|
|
`bin/gstack-decision-log '{"decision":"...","rationale":"...","scope":"repo|branch|issue","source":"user|skill|agent","confidence":1-10}'`.
|
|
Reverse a prior call with `--supersede <id>`; expunge an accidental secret with
|
|
`--redact <id>`; rewrite the log to the active set with `--compact`. Non-interactive
|
|
(never prompts), injection-sanitized, and HIGH-secret-blocking on write.
|
|
- **Durable means:** architecture choice, scope cut, tool/vendor choice, or a reversal
|
|
of a prior call. NOT a turn-level edit, a phrasing tweak, or anything trivially
|
|
re-derivable. Capture is curated at the source — log durable decisions only, or the
|
|
store becomes noise.
|
|
|
|
## GBrain Search Guidance (configured by /sync-gbrain)
|
|
<!-- gstack-gbrain-search-guidance:start -->
|
|
|
|
GBrain is set up and synced on this machine. The agent should prefer gbrain
|
|
over Grep when the question is semantic or when you don't know the exact
|
|
identifier yet.
|
|
|
|
**This worktree is pinned to a worktree-scoped code source** via the
|
|
`.gbrain-source` file in the repo root (kubectl-style context). Any
|
|
`gbrain code-def`, `code-refs`, `code-callers`, `code-callees`, or `query`
|
|
call from anywhere under this worktree routes to that source by default —
|
|
no `--source` flag needed. Conductor sibling worktrees of the same repo
|
|
each have their own pin and their own indexed pages, so semantic results
|
|
match the actual code on disk in this worktree.
|
|
|
|
Two indexed corpora available via the `gbrain` CLI:
|
|
- This worktree's code (auto-pinned via `.gbrain-source`).
|
|
- `~/.gstack/` curated memory (registered as `gstack-brain-<user>` source via
|
|
the existing federation pipeline).
|
|
|
|
Prefer gbrain when:
|
|
- "Where is X handled?" / semantic intent, no exact string yet:
|
|
`gbrain search "<terms>"` or `gbrain query "<question>"`
|
|
- "Where is symbol Y defined?" / symbol-based code questions:
|
|
`gbrain code-def <symbol>` or `gbrain code-refs <symbol>`
|
|
- "What calls Y?" / "What does Y depend on?":
|
|
`gbrain code-callers <symbol>` / `gbrain code-callees <symbol>`
|
|
- "What did we decide last time?" / past plans, retros, learnings:
|
|
`gbrain search "<terms>" --source gstack-brain-<user>`
|
|
|
|
Grep is still right for known exact strings, regex, multiline patterns, and
|
|
file globs. Run `/sync-gbrain` after meaningful code changes; for ongoing
|
|
auto-sync across all worktrees, run `gbrain autopilot --install` once per
|
|
machine — gbrain's daemon handles incremental refresh on a schedule.
|
|
|
|
Safety: don't run `/sync-gbrain` while `gbrain autopilot` is active — the
|
|
orchestrator refuses destructive source ops when it detects a running autopilot
|
|
to avoid racing it (#1734). Prefer registering user repos with `gbrain sources
|
|
add --path <dir>` (no `--url`): URL-managed sources can auto-reclone, and the
|
|
sync code walk for them requires an explicit `--allow-reclone` opt-in.
|
|
|
|
<!-- gstack-gbrain-search-guidance:end -->
|