mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
+22




![dependabot[bot]](/assets/img/avatar_default.png)
![capy-ai[bot]](/assets/img/avatar_default.png)



Garry Tan
Jim McKeeth
thearbajonart
capy-ai[bot]
dependabot[bot]
DanCanadian
Nader Helmy
Nader Helmy
Zee Zheng
Jack Thomas
Jayesh Betala
techcenter68
Mattias Petersson
Arno V
0xDevNinja
Benjamin D. Smith
Brian Majewski
neallee2012
Max Hogan
maxpetrusenkoagent
katlun-lgtm
mikehasa
Briac
JackJin
EmilianoU26
frosimanuel
Tom Lam
Yotam
Jinzhe
Xianying Tan
Boyard Engels
Jey2311
8bb6d65270
* docs: fix CONTRIBUTING.md test-tiers link in TESTING_INTERNALS The link resolved relative to docs/, pointing at a nonexistent docs/CONTRIBUTING.md. Adopts #3070. Co-authored-by: thearbajonart <334330110+thearbajonart@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci: refresh GitHub Actions SHA pins (setup-buildx 4.4.1, build-push 7.4.0, attest 4.2.2, setup-node 7.0.0) Rewrite of dependabot #2988. Every bumped `uses:` stays pinned to the full commit SHA (verified against the upstream tags) and now carries its exact version comment, including the lines that had none. setup-node 6.5.0 -> 7.0.0 is a major bump; its notes list an ESM migration, cache outputs and removal of a dummy NODE_AUTH_TOKEN export, none of which affect our node-version-only use. actionlint 1.7.11 (CI image) and 1.7.12 are clean. Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(openclaw): stop advising a per-spawn env for spawned sessions OpenClaw's sessions_spawn has no env argument (openclaw/openclaw#51654 was closed without adding one), so the documented env: { OPENCLAW_SESSION: "1" } silently did nothing. Point users at the ACP harness or Gateway environment instead and guard the old advice. Adopts #2749. Co-authored-by: DanCanadian <211164803+DanCanadian@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(benchmark): scope /benchmark routing to web page performance /benchmark claimed the bare words "benchmark" and "performance", so model and eval benchmark requests could route to the web performance skill instead of /benchmark-models. Narrow the description, Use-when list and triggers to web-specific phrases, point model/skill benchmarks at /benchmark-models, and split the root router line the same way. The catalog lead stays within the discovery budget ("Web performance ..."). Adopts #2302 and closes duplicate #2083 (rewritten on current main). Co-authored-by: Nader Helmy <31414549+creatornader@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(llms.txt): link skills by generated path, drop host-skipped skills gen-llms-txt built every skill link as <name>/SKILL.md, so the router pointed at a nonexistent gstack/SKILL.md (it is the root SKILL.md) and /claude-code pointed at a SKILL.md the Claude host never generates. Links now use the template's generated output path, and skills the Claude host skips are left out of the index. Adopts #2279. Co-authored-by: Nader Helmy <creator.nader@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ci): install Bun from the SHA-256-verified release archive in GitLab CI and Ubicloud setup Rewrite of #1713. .gitlab-ci.yml's .setup-bun anchor and scripts/ubicloud/setup-free-suite.sh still piped https://bun.sh/install into bash. Both now download the bun-v$BUN_VERSION linux-x64 release zip, check it against the same SHA-256 Dockerfile.ci uses (#1706) before unzipping, install it to ~/.bun/bin and assert `bun --version`. test/ci-image-cli-pin.test.ts runs curlPipedToShell over both files and binds their hash and BUN_VERSION to Dockerfile.ci's amd64 arm and package.json's engines floor, so a Bun bump has to move all of them together. Verified by running the GitLab anchor in debian:stable-slim (bash and sh) and the Ubicloud block in a temp HOME. Co-authored-by: Zee Zheng <228040220+spacegeologist@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(browse): viewport auto unpins a pinned viewport without a restart `viewport WxH` pinned the page with no way back short of `browse restart`, which also drops a headed /connect-chrome session. `viewport auto` (aliases `reset`, `unpin`) now undoes the pin: - headed: the context already launches with `viewport: null`, but Playwright cannot drop a page's emulated size (and re-applies it on new frame sessions), so the active tab gets a fresh page in the same context at the same URL or load-html content. Cookies and storage persist, the tab id and ownership stay, refs reset as on navigation, and the page follows the real window again. - headless: back to the 1280x720 launch default, tracked viewport included. `auto` with --scale is rejected. Fixes #1059. Adopts #2782 and #1881. Co-authored-by: Jack Thomas <17882510+loulanyue@users.noreply.github.com> Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(browse): screenshot accepts --clip=x,y,w,h and --selector=<css> Agent-generated commands often use the GNU `--flag=value` form, which threw "Unknown screenshot flag". Both value flags now accept it, through the same validation and conflict checks as the space-separated form. Adopts #1563. Co-authored-by: techcenter68 <75930234+techcenter68@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(benchmark): price Codex cached input at the cache rate, stop calling Gemini OAuth ready Codex reports cached_input_tokens as a subset of input_tokens, but the adapter billed all of input_tokens at the full input rate. Split them into disjoint uncached and cached counts (what estimateCostUsd expects) and show new+cached input in the table and Markdown reports. A stored Gemini OAuth file alone is now reported as not ready, with a reason naming GEMINI_API_KEY; key-based auth stays ready. Adopts the focused subset of #2283; the capacity error code and stdin change are left out (no captured failure or reproduced hang). Co-authored-by: Mattias Petersson <mattias.petersson@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): catch loosened-input consumers and stale user-facing strings The Enum & Value Completeness check fired only on newly added values, so a diff that widens what a validator, parser or guard accepts, and breaks an unchanged downstream consumer outside the diff, was a blind spot. Widen the check's trigger (heading unchanged) and add a consumer-grep bullet. Teach the maintainability specialist to flag user-facing strings whose guarding condition changed while the text did not. Adopts #2141 (rewritten on current main; the section heading is kept). Co-authored-by: Arno V <38716+aversini@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(global-discover): bucket Codex sessions by originator Every Codex rollout counted as one undifferentiated codex bucket, so /retro global could not tell interactive Codex Desktop work from codex_exec runs or Claude Code-driven calls. Normalize session_meta.payload.originator into desktop, exec, claude_code or other (missing, unknown and non-string values), report the split per repo as codex_originators and globally as tools.codex.originators, and print it in the summary. Existing totals are unchanged. Adopts #1488. Refs #1315. Co-authored-by: 0xDevNinja <manmit0x@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(hook-check): cover skill frontmatter hooks, sourced helpers, conflict markers and the caller's tsconfig Rewrite of #3066. bin/gstack-hook-check only listed setup's hooks plus /autoplan's by name, so the /careful and /freeze PreToolUse checks (also run by /guard and /investigate), /plan-ceo-review's hand-off hook and the helpers they source were never parse-checked before Claude Code ran them. - The hook list is setup's _hook_command_path arguments plus every gstack path on a `command:` line in a generated */SKILL.md frontmatter (9 hooks, up from 6). The generated files are read rather than the .tmpl sources because the /autoplan and /plan-ceo-review commands come from resolvers. - Each shell hook's gstack-tree helpers (`.`/`source` lines, assignments and shellcheck source= directives, followed transitively) are parsed too: careful/bin/hook-extract.sh and bin/gstack-state-root.sh for /careful and /freeze. - The shim, its helpers, each TypeScript entry and every local module the bundle used (bun build --metafile; entry only when bun lacks it) are scanned for conflict-marker lines, which still parse inside heredocs, strings and template literals. - bun build runs from the gstack root, so the caller's broken tsconfig.json or bunfig.toml no longer fails every TypeScript hook. Lists are newline separated, so a root with spaces works. Tests in test/setup-hook-syntax-gate.test.ts cover each case; the gstack-doctor fixture now copies autoplan/SKILL.md, whose frontmatter registers the hook its hooks-row test breaks. docs/troubleshooting.md describes the wider check. Co-authored-by: Benjamin D. Smith <7134745+BenjaminDSmithy@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): extend defensive-security framing to the security specialist and Red Team The "authorized defensive-security review" framing and fixture summary mode lived only in the adversarial subagent prompt. The security specialist and the Red Team also reason adversarially over the full diff, so repos whose test corpus is attack payloads could still get those passes refused. Move both paragraphs into scripts/resolvers/defensive-review.ts (the adversarial render stays byte-identical), state them once in the specialist dispatch for the security specialist and the Red Team, and have the Red Team prompt start from them. Testing and the other specialists keep full fixture reads; the codex host still strips Review Army. Parity caps for ship and review rise by the measured 1,380 bytes each (carve-guards notes). Adopts #1921 (rewritten on current main). Co-authored-by: Brian Majewski <1693720+bmajewski@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(team-init): make the required-mode hook shell-neutral (node CommonJS, no shell variables) Rewrite of #2217. Required team mode wrote a bash check-gstack.sh and registered it as "$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh", which only works when the hook runner is a POSIX shell. Under cmd.exe or PowerShell (Windows, Copilot CLI) the variable is not expanded and the .sh cannot run, so an intended allow or deny became a hook error. - The hook is now .claude/hooks/check-gstack.cjs: the same root search (GSTACK_ROOT, every host's global root, the migrated repo), with the home directory from HOME, then USERPROFILE, then os.homedir(). It prints {} when gstack resolves and the PreToolUse deny JSON with exit 2 when not. - The registered command is `node -e "require(require('path').resolve( process.env.CLAUDE_PROJECT_DIR || '.', '.claude', 'hooks', 'check-gstack.cjs'))"`: no `$` or `%`, so sh, cmd.exe and PowerShell all pass it through unchanged. - An existing check-gstack registration (matcher Skill or Skill|skill) is replaced in place; duplicates are dropped and unrelated hooks in the same entry kept. The legacy .sh is deleted only after the settings write succeeds (a tracked one joins the suggested git add), and is kept when bun is missing or the write fails, which now reports instead of claiming success. Tests: team-mode and the hook-schema test run the command as registered; test/team-init-hook-shell-neutral.test.ts runs it through the platform's default shell (cmd.exe on the Windows lane, which curates it in) and through PowerShell when installed (verified here in the pwsh 7 container). The routing-probe check for the bash `_GSTACK_ROOT` gate now pins the same contract in the .cjs: every host root probed, block only when none resolves. Co-authored-by: neallee2012 <1499497+neallee2012@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(setup-gbrain): register the gbrain MCP through bun.exe on Windows so no console window opens Rewrite of #2007, folding #1963's MCP notes. On Windows the local stdio registration stored the bun `gbrain` binstub; gbrain.exe starts bun in a new console, so every Claude Code session opened a visible terminal for the MCP (the contributor's process trees show it; bun.exe launched directly attaches to Claude Code's headless ConPTY). Under MINGW/MSYS/Cygwin the skill now resolves BUN_ROOT from BUN_INSTALL or ~/.bun and registers `$BUN_ROOT/bin/bun.exe <global node_modules gbrain/src/cli.ts> serve`. That path also covers a `bun link`ed clone, whose global node_modules entry links to it; gstack-gbrain-install records no other clone location. When bun.exe or the entry is missing it registers `$BUN_ROOT/bin/gbrain.exe` by its full name, because Claude Code does not apply PATHEXT to a stored path (#1963). POSIX keeps the binstub line. The skill also notes that `claude mcp list` can show "Failed to connect" while a live session holds a local PGLite brain's single-writer lock (#1963). test/setup-gbrain-windows-mcp.test.ts runs the generated fence under bash with stub uname/claude and a temp HOME for MINGW64, MSYS with BUN_INSTALL, a missing entry, and Linux. It is listed as Windows-incompatible (POSIX stubs); the console behavior needs a manual Windows check. Co-authored-by: Max Hogan <max@maxhogan.dev> Co-authored-by: maxpetrusenkoagent <280464918+maxpetrusenkoagent@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(hosts): rewrite every "Agent tool" phrasing for agent-runtime hosts The Agent-tool rewrite for hermes, gbrain, openclaw and factory keyed only on "use the Agent tool", but templates mostly say "via the Agent tool", "with the Agent tool", "multiple Agent tool calls" or "Use the Agent tool", so the Claude tool name leaked into those hosts' skills and sections. Add ordered rules after the existing key (possessive, "the Agent tool", then bare "Agent tool") mapping to sessions_spawn (gbrain/openclaw), delegate_task (hermes) and delegation (factory). No goldens change. Adopts #1935 (rewritten on current main, without the new goldens). Co-authored-by: katlun-lgtm <264247399+katlun-lgtm@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(setup-deploy): Vercel auto-deploys only when connected to the Git repo /setup-deploy stated that Vercel always deploys on push, which is false for CLI-only projects with no Git integration, and its nothing-detected path did not say why such a project is invisible to detection. Qualify the Vercel trigger (ask for and record the CLI deploy command otherwise) and note that `vercel link` creates .vercel/ so a re-run detects the project. Adopts the Vercel accuracy part of #2992 (rewritten on current main); the third-party bring-up tool pointer is not adopted. Co-authored-by: mikehasa <91774998+mikehasa@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(retro): persist recommendations so the next retro detects follow-through /retro produced "3 Things to Improve" but never saved them in its JSON snapshot, so the next retro could not tell whether last week's recommendations were acted on. Step 13 now drafts the items before saving and always writes a `recommendations` array ({category, text}, [] when empty); Step 12 scores each prior recommendation addressed/partial/open against this window's commits, files and metrics and renders a follow-through line, skipping silently for snapshots without the key. The report format ties the items to the saved array. Global mode is unchanged. Adopts #1853 (rewritten on current main). Refs #1834. Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(global-discover): count the interactive Codex CLI (codex_cli_rs) in its own cli bucket * feat(developer-profile): --reconcile backfills office-hours tenure from timeline.jsonl sessions[] is written only at office-hours Phase 4.5, so runs that ended earlier never counted toward tenure and the tier undercounted. Add a manual `--reconcile` that scans projects/*/timeline.jsonl for office-hours `completed` events and appends a count-only row ({date, mode, project_slug, signal_count: 0, signals: [], backfilled: true}) for each run with no sessions[] entry for the same project within 60 minutes, extended back by a duration_s under 8h. It is idempotent, skips malformed lines, and leaves the file untouched on a no-op. `--read` counts backfilled rows for SESSION_COUNT and TIER but takes LAST_*, DESIGN_*, CROSS_PROJECT and the nudge from logged sessions only. Not wired into the preamble yet; the office-hours greeting guard for backfill-only profiles is left to the office-hours owner. Adopts #2668 (rewritten on current main). Closes part (b) of #2657. Co-authored-by: Briac <137724549+briacSck@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): the publication hook approves /autoplan's own Reads after the skill turn Claude Code applies a skill's allowed-tools only to the turn that invoked it. On 2.1.292 the default fork-subagent mode runs reviewers in the background, and the completion notice starts a new turn, so later Reads outside the working directory (the next phase's section file, the close packet) raised permission cards. Skill hooks stay registered for the session, and a PreToolUse "allow" skips the card (reproduced in a PTY on 2.1.292). autoplan/bin/owned-read.ts approves a parent Read only when the guard itself returned no output, and only for this installation's autoplan/sections/*.md (canonical path or the gbrain :user render) or an immutable phase artifact in <repo>/.gstack/tmp/autoplan/autoplan-<phase>-*/ whose repo is the session's project directory or contains it. Denials, unverified allows, child calls and every other path are unchanged. renderSectionBase and userRenderRoot move there with it. Tests that asserted a verified driver Read as {} now expect the approval; autoplan-owned-read covers the shipped shim, the close packet without a journal, verified artifacts through the guard, look-alikes (foreign install, writable copy, symlink alias, other repository, other names) and a guard denial staying a denial. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): restore point and phase artifacts live in the project's .gstack/tmp/autoplan/ Skill hooks do not run inside subagents, so a reviewer launched after a background completion notice raised a card for its own input (native-prompt.md under the state root). Step 1 now puts RESTORE_PATH in the repository's git-excluded .gstack/tmp/autoplan/ (added to .git/info/exclude, mode 700), and the snapshot tool writes every phase artifact beside it, so /autoplan and its reviewers read them from the working directory without a card. The guard's bindings are unchanged: they anchor on the restore point's directory. Step 1 still prints the slug, which the old RESTORE_PATH carried. A free test runs the generated Step 1 block twice from a repository subdirectory: the restore point lands in <repo>/.gstack/tmp/autoplan/, the exclude line is written once and git status stays clean. The autoplan parity cap rises by the measured bytes (115,270 / 101,979 = 1.1303). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(evals): autoplan-guard-pty fails on any permission card after the reviewer's notice turn The scripted boundary now mirrors Step 1's layout (restore point and artifacts in the project's .gstack/tmp/autoplan/) and continues past the Phase 2 entry to dispatch the Phase 2 reviewer after the CEO reviewer's completion notice. The case asserts four verified guard allows, that the close packet Read, the Phase 2 section Read and the Phase 2 dispatch came after a task-notification turn, that the Phase 2 reviewer read its input, and that no permission card appeared at all. Still one trial on haiku, about 75-85 seconds. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): permission cards after a background reviewer, and where /autoplan keeps its files Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(outside-review): read a trailing '— Medium.' or '(High)' severity on a finding line, the shape Codex now writes * feat(design): honor OPENAI_BASE_URL for every design API call One resolver, openaiUrl() in design/src/auth.ts, now builds every OpenAI URL the design binary, its prototype and its live model check use. Setting OPENAI_BASE_URL sends image generation and screenshot analysis to an OpenAI-compatible gateway; unset or blank keeps api.openai.com. The prototype script now goes through receiptedFetch, so its egress is receipted like the rest, and receipts already record the gateway host. A source scan keeps any design file outside auth.ts from hardcoding an OpenAI API URL again, and a request-shape test proves the gateway reaches both the vision and image endpoints. Co-authored-by: JackJin <1037461232@qq.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: testing specialist treats negative assertions as decisions A new assertion that something is absent records a behavior removal and needs cited intent. A rename or removal in the diff can empty a standing negative assertion so it passes forever; the specialist now greps the suite for negative assertions naming the old value. Adopted from the testing-specialist part of a community PR; the scope-check hunk targeted text that no longer exists. Co-authored-by: EmilianoU26 <314541067+EmilianoU26@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: security specialist checks authorization a route-guard read misses Four access-control classes where the guard is correct and the object still leaks: error pages composed outside the guard, ownership surviving a role downgrade, list/detail/download paths checked as one, and a session mutated before the identity-switch callback is validated. Adopted to review/specialists/security.md only; the /cso hunk targeted a bullet list that is now an OWASP/ASVS table. Co-authored-by: EmilianoU26 <314541067+EmilianoU26@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: zero findings is valid; suppress six common false positives The checklist now says a clean diff may come back clean, and suppresses framework-owned error handling, long exhaustive switches, null derefs a type guard already narrows, N+1 on fixed-cardinality loops, hardcoded test fixture values and non-cryptographic Math.random(). The contributor's "missing input validation on an internal function" and "missing await on fire-and-forget" suppressions are left out: both can hide real bugs. Co-authored-by: frosimanuel <frosimanuel@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(todos): parked contributor ideas by theme; /autoplan permission follow-ups * design-review: capture code tokens before universal principles /design-review read only DESIGN.md and otherwise fell back to universal principles, so a project whose system lives in CSS custom properties, tailwind.config or a theme file got audited and mocked up against an invented aesthetic. The skill now reads code tokens before falling back, and the target-mockup brief reuses the captured colors, fonts, radii and spacing, changing layout only. The contributor's optional microcopy checks (domain vocabulary, human register) are not adopted here. Co-authored-by: Tom Lam <tomlam0811@berkeley.edu> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(sidebar): add a terminal text-size picker to the toolbar The sidebar terminal toolbar gets an XS-XL text-size picker beside Restart. SM (13px) stays the default, so nothing changes until a user picks a size; the choice persists in chrome.storage.local and applies to the live terminal, re-fitting xterm and re-sending the PTY size two frames later so the new character cell is measured first. A VM test runs the real sidepanel-terminal.js through one auto-connect and checks the creation size, the stored-size fallback, persistence and the live resize. Co-authored-by: Yotam <yotsurfing@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(design-shotgun): generate with Codex's built-in $imagegen on Codex On the Codex host, /design-shotgun now generates and edits mockups with Codex's documented $imagegen skill in its default built-in mode (the image_gen tool), so users signed in with ChatGPT need no OPENAI_API_KEY. The design binary stays for the local comparison board only, images are published with gstack-design-claim as before, and screenshot evolution loads the captured page with view_image before an $imagegen edit. Step 3c-3d moved into a DESIGN_SHOTGUN_GENERATION resolver that returns the existing design-binary text unchanged for every other host; the Claude output differs only in two host-neutral rewordings of the evolve-from-screenshot pointers. DESIGN_SETUP and the regenerate step of the board loop gain matching Codex-only text for design-shotgun. Co-authored-by: Jinzhe <hwang.phy@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * voice: ban the "load-bearing" stock phrase Models overuse "load-bearing"; it joins the AI-vocabulary ban line in both voice tiers. The tier-2 voice directive is also tightened (the meta "compressed for runtime" tag and the bounded closer's restated exemption clause) so the shared preamble shrinks by 163 bytes per tier-2+ skill, which also pays for the reply-language rule that follows. The ship goldens are refreshed for the new render. Co-authored-by: Nader Helmy <creator.nader@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * voice: reply in the language of the user's latest message Every skill's voice directive now tells the agent to reply in the language of the user's latest message unless asked otherwise, so long English skill text no longer drags a Chinese (or any non-English) session into English. Mixed-language sessions follow the latest message. Code, commands, paths, identifiers and quoted output stay verbatim, and at tier 2+ so do the machine-read question markers (D<N>, option letters, (recommended)). The rule costs 205 bytes per tier-2+ skill and 144 per tier-1 skill. A further 25-byte cut to the bounded closer's exemption list leaves the net for this wave at +17 bytes per tier-2+ skill and +158 per tier-1 skill. review, qa and browse caps rise with measured notes; the ship goldens are refreshed. Co-authored-by: Xianying Tan <shrektan@126.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(redact-prepush): committed exact-span allowlist at .gstack-redact-allowlist The pre-push hook never fed the engine's allowlist option, so a reviewed, known-benign value (a public dev-compose URL, a documented sample key) warned or blocked every push, and the only ways out were a bypass or reshaping code until the detector stopped matching. The hook now reads the repo-root .gstack-redact-allowlist from each pushed commit (never the working tree), one exact matched span per line with # comments. Only a finding whose matched text equals an entry is suppressed: a password alone, a substring, or a different key still reports and still blocks, and engine.* unscanned findings are untouched. Every push that carries entries prints the entry count and each suppressed finding's rule and file line. Oversized files (over 64 KiB) are ignored. gstack.redact.allowEmail keeps working as before. Co-authored-by: Boyard Engels <boyard.engels@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(brain-sync): opt-in removal publishing with a bulk-removal valve gstack-brain-sync never published a removal: a deleted synced file, or the old directory after a project slug migration, stayed on the remote forever and gbrain indexed both copies. New setting artifacts_sync_removals (off by default). When on, each drain commits and pushes, receipted like every push, the removal of tracked files that are gone from disk and match the sync allowlist. Transcript pages are never removed here, and a decision log goes only together with its active snapshot. One valve: a drain that would remove more than 20 files at once publishes none and says so. `gstack-brain-sync --publish-removals` lists the pending removals and `--publish-removals --yes` publishes them on demand, whatever the setting. Co-authored-by: Jey2311 <5177215+Jey2311@users.noreply.github.com> * feat(windows): setup.ps1 checks prerequisites and runs ./setup in Git Bash A PowerShell entry point for Windows users. It checks that Git for Windows, Bun and Node.js are on PATH (printing the winget command for each missing one and changing nothing), finds Git Bash from git.exe rather than a PATH bash that may be WSL's, and runs ./setup from the checkout with every argument passed as its own argv entry to a constant bash script. No argument is interpolated into shell source. ./setup itself is unchanged. Co-authored-by: Jim McKeeth <jim@mckeeth.org> * v1.91.63.0: CHANGELOG, version * fix(ci): link bunx after the SHA-verified Bun install in GitLab CI and the Ubicloud setup * fix(redact-prepush): strip every parent column from combined-diff lines, so a merged release bump is exempt like any other * fix(hook-check): treat a Windows drive path as absolute (cygpath when present), so --list finds the skill hooks from a native caller * fix(windows): owned-read requires the read-only attribute on Windows; setup.ps1 passes arguments NUL-separated through a private file; Windows-only tests expect the owned-read approval * perf(hook-check): bundle every hook's TypeScript entries in one bun process instead of two bun starts per entry (seconds each on Windows) * perf(hook-check): read all skill frontmatter in one awk and scan conflict markers with one grep per hook (half the process starts) --------- Co-authored-by: thearbajonart <334330110+thearbajonart@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: DanCanadian <211164803+DanCanadian@users.noreply.github.com> Co-authored-by: Nader Helmy <31414549+creatornader@users.noreply.github.com> Co-authored-by: Nader Helmy <creator.nader@gmail.com> Co-authored-by: Zee Zheng <228040220+spacegeologist@users.noreply.github.com> Co-authored-by: Jack Thomas <17882510+loulanyue@users.noreply.github.com> Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: techcenter68 <75930234+techcenter68@users.noreply.github.com> Co-authored-by: Mattias Petersson <mattias.petersson@gmail.com> Co-authored-by: Arno V <38716+aversini@users.noreply.github.com> Co-authored-by: 0xDevNinja <manmit0x@gmail.com> Co-authored-by: Benjamin D. Smith <7134745+BenjaminDSmithy@users.noreply.github.com> Co-authored-by: Brian Majewski <1693720+bmajewski@users.noreply.github.com> Co-authored-by: neallee2012 <1499497+neallee2012@users.noreply.github.com> Co-authored-by: Max Hogan <max@maxhogan.dev> Co-authored-by: maxpetrusenkoagent <280464918+maxpetrusenkoagent@users.noreply.github.com> Co-authored-by: katlun-lgtm <264247399+katlun-lgtm@users.noreply.github.com> Co-authored-by: mikehasa <91774998+mikehasa@users.noreply.github.com> Co-authored-by: Briac <137724549+briacSck@users.noreply.github.com> Co-authored-by: JackJin <1037461232@qq.com> Co-authored-by: EmilianoU26 <314541067+EmilianoU26@users.noreply.github.com> Co-authored-by: frosimanuel <frosimanuel@gmail.com> Co-authored-by: Tom Lam <tomlam0811@berkeley.edu> Co-authored-by: Yotam <yotsurfing@gmail.com> Co-authored-by: Jinzhe <hwang.phy@gmail.com> Co-authored-by: Xianying Tan <shrektan@126.com> Co-authored-by: Boyard Engels <boyard.engels@gmail.com> Co-authored-by: Jey2311 <5177215+Jey2311@users.noreply.github.com> Co-authored-by: Jim McKeeth <jim@mckeeth.org>
2223 lines
94 KiB
TypeScript
2223 lines
94 KiB
TypeScript
import { describe, test, expect, afterAll } from 'bun:test';
|
|
import { validateSkill, extractRemoteSlugPatterns, extractWeightsFromTable } from './helpers/skill-parser';
|
|
import { ALL_COMMANDS, COMMAND_DESCRIPTIONS, READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from '../browse/src/commands';
|
|
import { SNAPSHOT_FLAGS } from '../browse/src/snapshot';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { usePrivateStateRoot } from './helpers/private-state-root';
|
|
import { expectMentions } from './helpers/prompt-structure';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
|
|
// ─── Codex-host render isolation ─────────────────────────────
|
|
// .agents/ is gitignored and regenerated. This file used to regenerate it IN
|
|
// PLACE at three sites (a tree-mutating hazard for concurrent readers).
|
|
// Render the codex host ONCE into a module-level out-dir instead; every
|
|
// codex-artifact assertion reads from here. Out-dir renders are byte-
|
|
// identical to in-place external-host renders (pinned by
|
|
// test/gen-skill-docs-out-dir.test.ts).
|
|
const CODEX_OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-skillval-codex-'));
|
|
{
|
|
const render = Bun.spawnSync(
|
|
['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex', '--out-dir', CODEX_OUT],
|
|
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 120_000 },
|
|
);
|
|
if (render.exitCode !== 0) {
|
|
throw new Error(
|
|
`gen-skill-docs --host codex --out-dir failed (exit ${render.exitCode}):\n${render.stderr.toString()}`,
|
|
);
|
|
}
|
|
}
|
|
afterAll(() => {
|
|
fs.rmSync(CODEX_OUT, { recursive: true, force: true });
|
|
});
|
|
|
|
// Carved-skill aware (v2 plan T9 / Phase B): a carved skill is a skeleton SKILL.md
|
|
// plus sections/*.md. Read the union so validations of content that moved into a
|
|
// section still hold. For an uncarved skill (no sections dir) this is just the
|
|
// skeleton, so readSkillUnion is safe to use everywhere.
|
|
function readSkillUnion(skill: string): string {
|
|
let t = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
|
|
const secDir = path.join(ROOT, skill, 'sections');
|
|
if (fs.existsSync(secDir)) {
|
|
for (const f of fs.readdirSync(secDir).sort()) {
|
|
if (f.endsWith('.md')) t += '\n' + fs.readFileSync(path.join(secDir, f), 'utf-8');
|
|
}
|
|
}
|
|
return t;
|
|
}
|
|
function readShipUnion(): string {
|
|
return readSkillUnion('ship');
|
|
}
|
|
|
|
describe('CSO host permission boundary', () => {
|
|
test('grants no raw source, mutation, search, question, or Agent tools', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'cso', 'SKILL.md'), 'utf-8');
|
|
const fmEnd = content.indexOf('\n---', 4);
|
|
const frontmatter = Bun.YAML.parse(content.slice(4, fmEnd)) as Record<string, unknown>;
|
|
const allowed = frontmatter['allowed-tools'];
|
|
expect(allowed).toEqual([
|
|
'Bash(~/.claude/skills/gstack/bin/gstack-cso-launcher *)',
|
|
'Bash(~/.claude/skills/gstack/bin/gstack-cso-launcher.exe *)',
|
|
]);
|
|
for (const broad of ['Bash', 'Read', 'Grep', 'Glob', 'Write', 'Agent', 'WebSearch', 'AskUserQuestion']) expect(allowed).not.toContain(broad);
|
|
});
|
|
|
|
test('retains helper-only source access, sequential challenge, and honest host containment', () => {
|
|
const content = readSkillUnion('cso');
|
|
expect(content).toContain('Never use host `Read`/`Glob`/`Grep`');
|
|
expect(content).toContain('sequential challenge; independent agent unavailable');
|
|
expectMentions(content, [['do not', 'independent', 'reviewer']], 'content');
|
|
expectMentions(content, [['does not', 'containment', 'sandbox']], 'content');
|
|
});
|
|
});
|
|
|
|
function readCodexSkillUnion(skill: string): string {
|
|
const dir = path.join(CODEX_OUT, '.agents', 'skills', `gstack-${skill}`);
|
|
const sections = path.join(dir, 'sections');
|
|
return fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf-8')
|
|
+ (fs.existsSync(sections) ? fs.readdirSync(sections).sort()
|
|
.filter(file => file.endsWith('.md'))
|
|
.map(file => '\n' + fs.readFileSync(path.join(sections, file), 'utf-8')).join('') : '');
|
|
}
|
|
|
|
describe('SKILL.md command validation', () => {
|
|
// P2 (v1.2.0): the top-level gstack skill is a pure ROUTER, not the browse
|
|
// skill. The browse body lives only in browse/SKILL.md now. This regression
|
|
// pins the split: the router carries routing rules and zero browse commands,
|
|
// while browse/SKILL.md still advertises the full QA surface (asserted below).
|
|
test('top-level SKILL.md is a router with no browse body (P2)', () => {
|
|
const md = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
|
expect(md).not.toContain('gstack browse: QA Testing'); // browse body removed
|
|
expect(md).toContain('## Route first'); // router head present
|
|
expect(md).toContain('invoke `/investigate`'); // routing rules present
|
|
const result = validateSkill(path.join(ROOT, 'SKILL.md'));
|
|
expect(result.invalid).toHaveLength(0); // no INVALID browse commands
|
|
expect(result.valid.length).toBe(0); // and no browse commands at all — it routes, not browses
|
|
});
|
|
|
|
// Browse carve: the $B command reference renders into the on-demand section
|
|
// browse/sections/command-list.md; the skeleton carries the Aside contract
|
|
// (Aside is the primary browser, $B its fallback). Validate the union.
|
|
const BROWSE_DOCS = ['browse/SKILL.md', 'browse/sections/command-list.md']
|
|
.map((rel) => path.join(ROOT, rel)).filter((p) => fs.existsSync(p));
|
|
|
|
test('all $B commands in browse/SKILL.md + command-list section are valid browse commands', () => {
|
|
let validTotal = 0;
|
|
for (const doc of BROWSE_DOCS) {
|
|
const result = validateSkill(doc);
|
|
expect({ doc, invalid: result.invalid }).toEqual({ doc, invalid: [] });
|
|
validTotal += result.valid.length;
|
|
}
|
|
expect(validTotal).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('all snapshot flags in browse/SKILL.md + command-list section are valid', () => {
|
|
for (const doc of BROWSE_DOCS) {
|
|
const result = validateSkill(doc);
|
|
expect({ doc, snapshotFlagErrors: result.snapshotFlagErrors }).toEqual({ doc, snapshotFlagErrors: [] });
|
|
}
|
|
});
|
|
|
|
test('all $B commands in qa/SKILL.md are valid browse commands', () => {
|
|
const qaSkill = path.join(ROOT, 'qa', 'SKILL.md');
|
|
if (!fs.existsSync(qaSkill)) return; // skip if missing
|
|
const result = validateSkill(qaSkill);
|
|
expect(result.invalid).toHaveLength(0);
|
|
});
|
|
|
|
test('all snapshot flags in qa/SKILL.md are valid', () => {
|
|
const qaSkill = path.join(ROOT, 'qa', 'SKILL.md');
|
|
if (!fs.existsSync(qaSkill)) return;
|
|
const result = validateSkill(qaSkill);
|
|
expect(result.snapshotFlagErrors).toHaveLength(0);
|
|
});
|
|
|
|
// qa carve: the Phases 1-6 methodology (with its $B command examples) moved
|
|
// into qa/sections/*.md — validate the section files too so browse-command
|
|
// coverage doesn't silently shrink with the carve.
|
|
test('all $B commands and snapshot flags in qa/sections/*.md are valid', () => {
|
|
const secDir = path.join(ROOT, 'qa', 'sections');
|
|
if (!fs.existsSync(secDir)) return; // pre-carve checkout
|
|
const sectionMds = fs.readdirSync(secDir).filter(f => f.endsWith('.md') && !f.endsWith('.md.tmpl'));
|
|
expect(sectionMds.length).toBeGreaterThan(0);
|
|
for (const f of sectionMds) {
|
|
const result = validateSkill(path.join(secDir, f));
|
|
expect({ file: f, invalid: result.invalid }).toEqual({ file: f, invalid: [] });
|
|
expect({ file: f, snapshotFlagErrors: result.snapshotFlagErrors }).toEqual({ file: f, snapshotFlagErrors: [] });
|
|
}
|
|
});
|
|
|
|
test('all $B commands in qa-only/SKILL.md are valid browse commands', () => {
|
|
const qaOnlySkill = path.join(ROOT, 'qa-only', 'SKILL.md');
|
|
if (!fs.existsSync(qaOnlySkill)) return;
|
|
const result = validateSkill(qaOnlySkill);
|
|
expect(result.invalid).toHaveLength(0);
|
|
});
|
|
|
|
test('all snapshot flags in qa-only/SKILL.md are valid', () => {
|
|
const qaOnlySkill = path.join(ROOT, 'qa-only', 'SKILL.md');
|
|
if (!fs.existsSync(qaOnlySkill)) return;
|
|
const result = validateSkill(qaOnlySkill);
|
|
expect(result.snapshotFlagErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('all $B commands in plan-design-review/SKILL.md are valid browse commands', () => {
|
|
const skill = path.join(ROOT, 'plan-design-review', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.invalid).toHaveLength(0);
|
|
});
|
|
|
|
test('all snapshot flags in plan-design-review/SKILL.md are valid', () => {
|
|
const skill = path.join(ROOT, 'plan-design-review', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.snapshotFlagErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('all $B commands in design-review/SKILL.md are valid browse commands', () => {
|
|
const skill = path.join(ROOT, 'design-review', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.invalid).toHaveLength(0);
|
|
});
|
|
|
|
test('all snapshot flags in design-review/SKILL.md are valid', () => {
|
|
const skill = path.join(ROOT, 'design-review', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.snapshotFlagErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('all $B commands in design-consultation/SKILL.md are valid browse commands', () => {
|
|
const skill = path.join(ROOT, 'design-consultation', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.invalid).toHaveLength(0);
|
|
});
|
|
|
|
test('all snapshot flags in design-consultation/SKILL.md are valid', () => {
|
|
const skill = path.join(ROOT, 'design-consultation', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.snapshotFlagErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('all $B commands in autoplan/SKILL.md are valid browse commands', () => {
|
|
const skill = path.join(ROOT, 'autoplan', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.invalid).toHaveLength(0);
|
|
});
|
|
|
|
test('all snapshot flags in autoplan/SKILL.md are valid', () => {
|
|
const skill = path.join(ROOT, 'autoplan', 'SKILL.md');
|
|
if (!fs.existsSync(skill)) return;
|
|
const result = validateSkill(skill);
|
|
expect(result.snapshotFlagErrors).toHaveLength(0);
|
|
});
|
|
|
|
test('autoplan section skip list includes the scope gate', () => {
|
|
// autoplan Step 3 reads plan-eng-review / plan-design-review SKILL.md
|
|
// verbatim; without this skip-list entry it ingests their scope gate — a
|
|
// hard-STOP AskUserQuestion that contradicts autoplan's auto-decide
|
|
// contract. Nothing else pins the skip-list contents.
|
|
const md = fs.readFileSync(path.join(ROOT, 'autoplan', 'SKILL.md'), 'utf-8');
|
|
expect(md).toContain('- Scope gate (the plan under review is already the target)');
|
|
});
|
|
});
|
|
|
|
describe('Command registry consistency', () => {
|
|
test('COMMAND_DESCRIPTIONS covers all commands in sets', () => {
|
|
const allCmds = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
|
const descKeys = new Set(Object.keys(COMMAND_DESCRIPTIONS));
|
|
for (const cmd of allCmds) {
|
|
expect(descKeys.has(cmd)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('COMMAND_DESCRIPTIONS has no extra commands not in sets', () => {
|
|
const allCmds = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
|
for (const key of Object.keys(COMMAND_DESCRIPTIONS)) {
|
|
expect(allCmds.has(key)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('ALL_COMMANDS matches union of all sets', () => {
|
|
const union = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
|
expect(ALL_COMMANDS.size).toBe(union.size);
|
|
for (const cmd of union) {
|
|
expect(ALL_COMMANDS.has(cmd)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('SNAPSHOT_FLAGS option keys are valid SnapshotOptions fields', () => {
|
|
const validKeys = new Set([
|
|
'interactive', 'compact', 'depth', 'selector',
|
|
'diff', 'annotate', 'outputPath', 'cursorInteractive',
|
|
'heatmap',
|
|
]);
|
|
for (const flag of SNAPSHOT_FLAGS) {
|
|
expect(validKeys.has(flag.optionKey)).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Usage string consistency', () => {
|
|
// Normalize a usage string to its structural skeleton for comparison.
|
|
// Replaces <param-names> with <>, [optional] with [], strips parenthetical hints.
|
|
// This catches format mismatches (e.g., <name>:<value> vs <name> <value>)
|
|
// without tripping on abbreviation differences (e.g., <sel> vs <selector>).
|
|
function skeleton(usage: string): string {
|
|
return usage
|
|
.replace(/\(.*?\)/g, '') // strip parenthetical hints like (e.g., Enter, Tab)
|
|
.replace(/<[^>]*>/g, '<>') // normalize <param-name> → <>
|
|
.replace(/\[[^\]]*\]/g, '[]') // normalize [optional] → []
|
|
.replace(/\s+/g, ' ') // collapse whitespace
|
|
.trim();
|
|
}
|
|
|
|
// Cross-check Usage: patterns in implementation against COMMAND_DESCRIPTIONS
|
|
test('implementation Usage: structural format matches COMMAND_DESCRIPTIONS', () => {
|
|
const implFiles = [
|
|
path.join(ROOT, 'browse', 'src', 'write-commands.ts'),
|
|
path.join(ROOT, 'browse', 'src', 'read-commands.ts'),
|
|
path.join(ROOT, 'browse', 'src', 'meta-commands.ts'),
|
|
];
|
|
|
|
// Extract "Usage: browse <pattern>" from throw new Error(...) calls
|
|
const usagePattern = /throw new Error\(['"`]Usage:\s*browse\s+(.+?)['"`]\)/g;
|
|
const implUsages = new Map<string, string>();
|
|
|
|
for (const file of implFiles) {
|
|
const content = fs.readFileSync(file, 'utf-8');
|
|
let match;
|
|
while ((match = usagePattern.exec(content)) !== null) {
|
|
const usage = match[1].split('\\n')[0].trim();
|
|
const cmd = usage.split(/\s/)[0];
|
|
implUsages.set(cmd, usage);
|
|
}
|
|
}
|
|
|
|
// Compare structural skeletons
|
|
const mismatches: string[] = [];
|
|
for (const [cmd, implUsage] of implUsages) {
|
|
const desc = COMMAND_DESCRIPTIONS[cmd];
|
|
if (!desc) continue;
|
|
if (!desc.usage) continue;
|
|
const descSkel = skeleton(desc.usage);
|
|
const implSkel = skeleton(implUsage);
|
|
if (descSkel !== implSkel) {
|
|
mismatches.push(`${cmd}: docs "${desc.usage}" (${descSkel}) vs impl "${implUsage}" (${implSkel})`);
|
|
}
|
|
}
|
|
|
|
expect(mismatches).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// --- Update check preamble validation ---
|
|
|
|
describe('Update check preamble', () => {
|
|
const skillsWithUpdateCheck = [
|
|
'SKILL.md', 'browse/SKILL.md', 'qa/SKILL.md',
|
|
'qa-only/SKILL.md',
|
|
'setup-browser-cookies/SKILL.md',
|
|
'ship/SKILL.md', 'review/SKILL.md',
|
|
'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
|
|
'retro/SKILL.md',
|
|
'office-hours/SKILL.md', 'investigate/SKILL.md',
|
|
'plan-design-review/SKILL.md',
|
|
'design-review/SKILL.md',
|
|
'design-consultation/SKILL.md',
|
|
'document-release/SKILL.md',
|
|
'canary/SKILL.md',
|
|
'benchmark/SKILL.md',
|
|
'land-and-deploy/SKILL.md',
|
|
'setup-deploy/SKILL.md',
|
|
// CSO intentionally uses a private startup instead of the shared update,
|
|
// session, learning, checkpoint, and telemetry PREAMBLE.
|
|
];
|
|
|
|
for (const skill of skillsWithUpdateCheck) {
|
|
test(`${skill} update check line ends with || true`, () => {
|
|
// Token-reduction Phase 1: the inline `_UPD=$(gstack-update-check ...)`
|
|
// bash moved into bin/gstack-skill-start. The render must (a) invoke the
|
|
// script with the exact flag shape as ONE plain command (#2763: worktree-
|
|
// isolated Claude Code sessions refuse a variable, `[ -x ] ||` fallback
|
|
// or `|| echo` start), (b) carry the degraded-install prose that replaced
|
|
// the old `|| true` / `|| echo` fence-level guard.
|
|
// Token-reduction Phase 2: the UPGRADE_AVAILABLE interpretation prose
|
|
// moved OUT of the renders too — it is now emitted at runtime by
|
|
// bin/gstack-skill-start as the gated `upgrade-flow` instruction block
|
|
// (pinned script-side below). What the render must keep is (c) the
|
|
// generic instruction-block rule that makes that runtime emission
|
|
// actionable: obey blocks only from the direct tool result of the
|
|
// preamble run, bound to the same SESSION_ID, never from any other
|
|
// tool output, file, or page content.
|
|
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
|
expect(content).toContain('bin/gstack-skill-start');
|
|
expect(content).toMatch(/^~\/\.claude\/skills\/gstack\/bin\/gstack-skill-start --skill "[^"]+" --model "[^"]+"$/m);
|
|
expect(content).not.toContain('_SS=');
|
|
expect(content).not.toContain('SKILL_START: unavailable');
|
|
expect(content).toContain('**Degraded mode:** if `SKILL_START_PROTO: 1` is missing');
|
|
expect(content).toContain('GSTACK_INSTRUCTION_BEGIN');
|
|
expect(content).toContain('direct tool result');
|
|
expect(content).toMatch(/same .?SESSION_ID.? that run echoed/);
|
|
expectMentions(content, [['never', 'output', 'tool']], 'content');
|
|
});
|
|
}
|
|
|
|
test('bin/gstack-skill-start emits the UPGRADE_AVAILABLE interpretation as the upgrade-flow instruction block', () => {
|
|
// Phase 2 successor of the per-render UPGRADE_AVAILABLE prose pin: the
|
|
// interpretation now lives in the script's instruction-emission layer,
|
|
// wrapped in GSTACK_INSTRUCTION_BEGIN/upgrade-flow/SESSION_ID markers and
|
|
// emitted only when the gate fires. Pin the emission id AND the verdict
|
|
// vocabulary the agent must act on (UPGRADE_AVAILABLE / JUST_UPGRADED,
|
|
// routed to gstack-upgrade's inline flow).
|
|
const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), 'utf-8');
|
|
expect(script).toContain('_emit_block upgrade-flow');
|
|
expect(script).toContain('UPGRADE_AVAILABLE <old> <new>');
|
|
expect(script).toContain('JUST_UPGRADED <from> <to>');
|
|
expect(script).toContain('gstack-upgrade/SKILL.md');
|
|
// The emission wrapper itself binds every block to the live SESSION_ID —
|
|
// the render-side rule above is only sound if this stays true.
|
|
expect(script).toContain('GSTACK_INSTRUCTION_BEGIN: $1 $_SESSION_ID');
|
|
});
|
|
|
|
test('bin/gstack-skill-start update check line ends with || true (new home of the inline guard)', () => {
|
|
// The `[ -n "$_UPD" ] ... || true` guard (empty _UPD must not exit 1 when
|
|
// up to date) moved verbatim into the consolidated preamble script. Pin it
|
|
// there so the invariant survives in its new home.
|
|
const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), 'utf-8');
|
|
const match = script.match(/\[ -n "\$_UPD" \].*$/m);
|
|
expect(match).not.toBeNull();
|
|
expect(match![0]).toContain('|| true');
|
|
});
|
|
|
|
test('all skills with update check are generated from .tmpl', () => {
|
|
for (const skill of skillsWithUpdateCheck) {
|
|
const tmplPath = path.join(ROOT, skill + '.tmpl');
|
|
expect(fs.existsSync(tmplPath)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('update check bash block exits 0 when up to date', () => {
|
|
// Simulate the exact update-check lines from bin/gstack-skill-start
|
|
// (per-line `|| true`, sanitize pipe included)
|
|
const result = Bun.spawnSync(['bash', '-c',
|
|
'_sanitize() { sed "s/GSTACK_INSTRUCTION/GSTACK-INSTRUCTION-(stripped)/g"; }; ' +
|
|
'_UPD=$(echo "" || true); [ -n "$_UPD" ] && printf "%s\\n" "$_UPD" | _sanitize || true'
|
|
], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
expect(result.exitCode).toBe(0);
|
|
});
|
|
|
|
test('update check bash block exits 0 when upgrade available', () => {
|
|
const result = Bun.spawnSync(['bash', '-c',
|
|
'_sanitize() { sed "s/GSTACK_INSTRUCTION/GSTACK-INSTRUCTION-(stripped)/g"; }; ' +
|
|
'_UPD=$(echo "UPGRADE_AVAILABLE 0.3.3 0.4.0" || true); [ -n "$_UPD" ] && printf "%s\\n" "$_UPD" | _sanitize || true'
|
|
], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout.toString().trim()).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
|
|
});
|
|
});
|
|
|
|
// --- Part 7: Cross-skill path consistency (A1) ---
|
|
|
|
describe('Cross-skill path consistency', () => {
|
|
test('REMOTE_SLUG derivation pattern is identical across files that use it', () => {
|
|
const patterns = extractRemoteSlugPatterns(ROOT, ['qa', 'review']);
|
|
const allPatterns: string[] = [];
|
|
|
|
for (const [, filePatterns] of patterns) {
|
|
allPatterns.push(...filePatterns);
|
|
}
|
|
|
|
// Should find at least 2 occurrences (qa/SKILL.md + review/greptile-triage.md)
|
|
expect(allPatterns.length).toBeGreaterThanOrEqual(2);
|
|
|
|
// All occurrences must be character-for-character identical
|
|
const unique = new Set(allPatterns);
|
|
if (unique.size > 1) {
|
|
const variants = Array.from(unique);
|
|
throw new Error(
|
|
`REMOTE_SLUG pattern differs across files:\n` +
|
|
variants.map((v, i) => ` ${i + 1}: ${v}`).join('\n')
|
|
);
|
|
}
|
|
});
|
|
|
|
test('all greptile-history write references specify both per-project and global paths', () => {
|
|
const filesToCheck = [
|
|
'review/SKILL.md',
|
|
'ship/SKILL.md',
|
|
'review/greptile-triage.md',
|
|
];
|
|
|
|
for (const file of filesToCheck) {
|
|
const filePath = path.join(ROOT, file);
|
|
if (!fs.existsSync(filePath)) continue;
|
|
// ship's greptile handling moved into sections/greptile.md (T9 carve).
|
|
const content = file === 'ship/SKILL.md' ? readShipUnion() : fs.readFileSync(filePath, 'utf-8');
|
|
|
|
const hasBoth = (content.includes('per-project') && content.includes('global')) ||
|
|
(content.includes('$REMOTE_SLUG/greptile-history') && content.includes('~/.gstack/greptile-history'));
|
|
|
|
expect(hasBoth).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('greptile-triage.md contains both project and global history paths', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'review', 'greptile-triage.md'), 'utf-8');
|
|
expect(content).toContain('$REMOTE_SLUG/greptile-history.md');
|
|
expect(content).toContain('~/.gstack/greptile-history.md');
|
|
});
|
|
|
|
test('retro/SKILL.md reads global greptile-history (not per-project)', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('the path `GREPTILE_HISTORY` printed');
|
|
expect(fs.readFileSync(path.join(ROOT, 'bin', 'gstack-retro-metrics'), 'utf-8')).toContain('$_GH/greptile-history.md');
|
|
// Should NOT reference per-project path for reads
|
|
expect(content).not.toContain('$REMOTE_SLUG/greptile-history.md');
|
|
});
|
|
});
|
|
|
|
// --- Part 7: QA skill structure validation (A2) ---
|
|
|
|
describe('QA skill structure validation', () => {
|
|
// qa carve: modes, Phases 1-6, and the health rubric moved into
|
|
// qa/sections/qa-patterns.md — validate the skeleton+sections union.
|
|
const qaContent = readSkillUnion('qa');
|
|
|
|
test('qa/SKILL.md has all 11 phases', () => {
|
|
const phases = [
|
|
'Phase 1', 'Initialize',
|
|
'Phase 2', 'Authenticate',
|
|
'Phase 3', 'Orient',
|
|
'Phase 4', 'Explore',
|
|
'Phase 5', 'Document',
|
|
'Phase 6', 'Wrap Up',
|
|
'Phase 7', 'Triage',
|
|
'Phase 8', 'Fix Loop',
|
|
'Phase 9', 'Final QA',
|
|
'Phase 10', 'Report',
|
|
'Phase 11', 'TODOS',
|
|
];
|
|
for (const phase of phases) {
|
|
expect(qaContent).toContain(phase);
|
|
}
|
|
});
|
|
|
|
test('has all four QA modes defined', () => {
|
|
const modes = [
|
|
'Diff-aware',
|
|
'Full',
|
|
'Quick',
|
|
'Regression',
|
|
];
|
|
for (const mode of modes) {
|
|
expect(qaContent).toContain(mode);
|
|
}
|
|
|
|
// Mode triggers/flags
|
|
expect(qaContent).toContain('--quick');
|
|
expect(qaContent).toContain('--regression');
|
|
});
|
|
|
|
test('has all three tiers defined', () => {
|
|
const tiers = ['Quick', 'Standard', 'Exhaustive'];
|
|
for (const tier of tiers) {
|
|
expect(qaContent).toContain(tier);
|
|
}
|
|
});
|
|
|
|
test('health score weights sum to 100%', () => {
|
|
const weights = extractWeightsFromTable(qaContent);
|
|
expect(weights.size).toBeGreaterThan(0);
|
|
|
|
let sum = 0;
|
|
for (const pct of weights.values()) {
|
|
sum += pct;
|
|
}
|
|
expect(sum).toBe(100);
|
|
});
|
|
|
|
test('health score has all 8 categories', () => {
|
|
const weights = extractWeightsFromTable(qaContent);
|
|
const expectedCategories = [
|
|
'Console', 'Links', 'Visual', 'Functional',
|
|
'UX', 'Performance', 'Content', 'Accessibility',
|
|
];
|
|
for (const cat of expectedCategories) {
|
|
expect(weights.has(cat)).toBe(true);
|
|
}
|
|
expect(weights.size).toBe(8);
|
|
});
|
|
|
|
test('has four mode definitions (Diff-aware/Full/Quick/Regression)', () => {
|
|
expect(qaContent).toContain('### Diff-aware');
|
|
expect(qaContent).toContain('### Full');
|
|
expect(qaContent).toContain('### Quick');
|
|
expect(qaContent).toContain('### Regression');
|
|
});
|
|
|
|
test('output structure references report directory layout', () => {
|
|
expect(qaContent).toContain('qa-report-');
|
|
expect(qaContent).toContain('baseline.json');
|
|
expect(qaContent).toContain('screenshots/');
|
|
expect(qaContent).toContain('.gstack/qa-reports/');
|
|
});
|
|
});
|
|
|
|
// --- Part 7: Greptile history format consistency (A3) ---
|
|
|
|
describe('Greptile history format consistency', () => {
|
|
test('greptile-triage.md defines the canonical history format', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'review', 'greptile-triage.md'), 'utf-8');
|
|
expect(content).toContain('<YYYY-MM-DD>');
|
|
expect(content).toContain('<owner/repo>');
|
|
expect(content).toContain('<type');
|
|
expect(content).toContain('<file-pattern>');
|
|
expect(content).toContain('<category>');
|
|
});
|
|
|
|
test('review/SKILL.md and ship/SKILL.md both reference greptile-triage.md for write details', () => {
|
|
const reviewContent = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
|
const shipContent = readShipUnion();
|
|
|
|
expect(reviewContent.toLowerCase()).toContain('greptile-triage.md');
|
|
expect(shipContent.toLowerCase()).toContain('greptile-triage.md');
|
|
});
|
|
|
|
test('greptile-triage.md defines all 9 valid categories', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'review', 'greptile-triage.md'), 'utf-8');
|
|
const categories = [
|
|
'race-condition', 'null-check', 'error-handling', 'style',
|
|
'type-safety', 'security', 'performance', 'correctness', 'other',
|
|
];
|
|
for (const cat of categories) {
|
|
expect(content).toContain(cat);
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- Hardcoded branch name detection in templates ---
|
|
|
|
describe('No hardcoded branch names in SKILL templates', () => {
|
|
const tmplFiles = [
|
|
'ship/SKILL.md.tmpl',
|
|
'review/SKILL.md.tmpl',
|
|
'qa/SKILL.md.tmpl',
|
|
'plan-ceo-review/SKILL.md.tmpl',
|
|
'retro/SKILL.md.tmpl',
|
|
'document-release/SKILL.md.tmpl',
|
|
'plan-eng-review/SKILL.md.tmpl',
|
|
'plan-design-review/SKILL.md.tmpl',
|
|
'codex/SKILL.md.tmpl',
|
|
];
|
|
|
|
// Patterns that indicate hardcoded 'main' in git commands
|
|
const gitMainPatterns = [
|
|
/\bgit\s+diff\s+(?:origin\/)?main\b/,
|
|
/\bgit\s+log\s+(?:origin\/)?main\b/,
|
|
/\bgit\s+fetch\s+origin\s+main\b/,
|
|
/\bgit\s+merge\s+origin\/main\b/,
|
|
/\borigin\/main\b/,
|
|
];
|
|
|
|
// Lines that are allowed to mention 'main' (fallback logic, prose)
|
|
const allowlist = [
|
|
/fall\s*back\s+to\s+`main`/i,
|
|
/fall\s*back\s+to\s+`?main`?/i,
|
|
/typically\s+`?main`?/i,
|
|
/If\s+on\s+`main`/i, // old pattern — should not exist
|
|
];
|
|
|
|
for (const tmplFile of tmplFiles) {
|
|
test(`${tmplFile} has no hardcoded 'main' in git commands`, () => {
|
|
const filePath = path.join(ROOT, tmplFile);
|
|
if (!fs.existsSync(filePath)) return;
|
|
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
|
|
const violations: string[] = [];
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const isAllowlisted = allowlist.some(p => p.test(line));
|
|
if (isAllowlisted) continue;
|
|
|
|
for (const pattern of gitMainPatterns) {
|
|
if (pattern.test(line)) {
|
|
violations.push(`Line ${i + 1}: ${line.trim()}`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
throw new Error(
|
|
`${tmplFile} has hardcoded 'main' in git commands:\n` +
|
|
violations.map(v => ` ${v}`).join('\n')
|
|
);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// --- Part 7b: TODOS-format.md reference consistency ---
|
|
|
|
describe('TODOS-format.md reference consistency', () => {
|
|
test('review/TODOS-format.md exists and defines canonical format', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'review', 'TODOS-format.md'), 'utf-8');
|
|
expect(content).toContain('**What:**');
|
|
expect(content).toContain('**Why:**');
|
|
expect(content).toContain('**Priority:**');
|
|
expect(content).toContain('**Effort:**');
|
|
expect(content).toContain('## Completed');
|
|
});
|
|
|
|
test('skills that write TODOs reference TODOS-format.md', () => {
|
|
const shipContent = readShipUnion();
|
|
const ceoPlanContent = readSkillUnion('plan-ceo-review'); // carved: TODOS-format ref moved to section
|
|
const engPlanContent = readSkillUnion('plan-eng-review');
|
|
|
|
expect(shipContent).toContain('TODOS-format.md');
|
|
expect(ceoPlanContent).toContain('TODOS-format.md');
|
|
expect(engPlanContent).toContain('TODOS-format.md');
|
|
});
|
|
});
|
|
|
|
// --- v0.4.1 feature coverage: RECOMMENDATION format, session awareness, enum completeness ---
|
|
|
|
describe('v0.4.1 preamble features', () => {
|
|
// Tier 1 skills have core preamble only (no AskUserQuestion format)
|
|
const tier1Skills = ['SKILL.md', 'browse/SKILL.md', 'setup-browser-cookies/SKILL.md', 'benchmark/SKILL.md'];
|
|
|
|
// Tier 2+ skills have AskUserQuestion format with RECOMMENDATION
|
|
const tier2PlusSkills = [
|
|
'qa/SKILL.md', 'qa-only/SKILL.md',
|
|
'ship/SKILL.md', 'review/SKILL.md',
|
|
'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
|
|
'retro/SKILL.md',
|
|
'office-hours/SKILL.md', 'investigate/SKILL.md',
|
|
'plan-design-review/SKILL.md',
|
|
'design-review/SKILL.md',
|
|
'design-consultation/SKILL.md',
|
|
'document-release/SKILL.md',
|
|
'canary/SKILL.md',
|
|
'land-and-deploy/SKILL.md',
|
|
'setup-deploy/SKILL.md',
|
|
// CSO's private startup intentionally omits the generic AUQ/session/
|
|
// escalation PREAMBLE; its helper owns readiness and terminal state.
|
|
];
|
|
|
|
const skillsWithPreamble = [...tier1Skills, ...tier2PlusSkills];
|
|
|
|
for (const skill of tier2PlusSkills) {
|
|
test(`${skill} contains AskUserQuestion Pros/Cons format`, () => {
|
|
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
|
// v1.7.0.0 Pros/Cons format tokens. The preamble resolver
|
|
// (generate-ask-user-format.ts) injects all of these into every
|
|
// tier-2+ skill. Drop any of them and the test catches it on the
|
|
// next `bun test` run.
|
|
expect(content).toContain('AskUserQuestion');
|
|
expect(content).toContain('Pros / cons:');
|
|
expect(content).toContain('Recommendation: <choice>');
|
|
expect(content).toContain('Net:');
|
|
expect(content).toContain('ELI10');
|
|
expect(content).toContain('Stakes if we pick wrong:');
|
|
// Concrete format markers must be documented in the resolver text
|
|
expect(content).toMatch(/✅/);
|
|
expect(content).toMatch(/❌/);
|
|
});
|
|
}
|
|
|
|
for (const skill of skillsWithPreamble) {
|
|
test(`${skill} contains session awareness`, () => {
|
|
// Token-reduction Phase 1: the inline `_SESSIONS=$(find ~/.gstack/sessions ...)`
|
|
// bash moved into bin/gstack-skill-start. Session identity (the harness
|
|
// pid that names the sessions-dir entry) is derived by the script itself,
|
|
// so the render passes no "$PPID" (#2763); it still carries the
|
|
// SESSION_KIND STATUS-line interpretation prose.
|
|
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
|
expect(content).not.toContain('$PPID');
|
|
expect(content).toContain('SESSION_KIND');
|
|
});
|
|
}
|
|
|
|
test('bin/gstack-skill-start owns the session-tracking machinery (new home of _SESSIONS)', () => {
|
|
// The sessions-dir touch + stale-session cleanup that every preamble used
|
|
// to inline now lives in the consolidated script — pin it there.
|
|
const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), 'utf-8');
|
|
expect(script).toContain('mkdir -p "$_GH/sessions"');
|
|
expect(script).toContain('touch "$_GH/sessions/$PARENT_PID"');
|
|
expect(script).toContain('-mmin +120'); // 120-min freshness window survives the move
|
|
});
|
|
|
|
for (const skill of skillsWithPreamble) {
|
|
test(`${skill} contains escalation protocol`, () => {
|
|
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
|
expect(content).toContain('DONE_WITH_CONCERNS');
|
|
expect(content).toContain('BLOCKED');
|
|
expect(content).toContain('NEEDS_CONTEXT');
|
|
});
|
|
}
|
|
});
|
|
|
|
// --- Structural tests for new skills ---
|
|
|
|
describe('office-hours skill structure', () => {
|
|
// Carved (v2 plan T9): Phase 5 (Design Doc) + Phase 6 (handoff) moved into
|
|
// sections/design-and-handoff.md, so structural phrases now live there — read
|
|
// the skeleton+sections union.
|
|
const content = readSkillUnion('office-hours');
|
|
|
|
// Original structural assertions
|
|
for (const section of ['Phase 1', 'Phase 2', 'Phase 3', 'Phase 4', 'Phase 5', 'Phase 6',
|
|
'Design Doc', 'Supersedes', 'APPROVED', 'Premise Challenge',
|
|
'Alternatives', 'Smart-skip']) {
|
|
test(`contains ${section}`, () => expect(content).toContain(section));
|
|
}
|
|
|
|
// Dual-mode structure
|
|
for (const section of ['Startup mode', 'Builder mode']) {
|
|
test(`contains ${section}`, () => expect(content).toContain(section));
|
|
}
|
|
|
|
// Mode detection question
|
|
test('contains explicit mode detection question', () => {
|
|
expect(content).toContain("what's your goal");
|
|
});
|
|
|
|
// Six forcing questions (startup mode)
|
|
for (const question of ['Demand Reality', 'Status Quo', 'Desperate Specificity',
|
|
'Narrowest Wedge', 'Observation & Surprise', 'Future-Fit']) {
|
|
test(`contains forcing question: ${question}`, () => expect(content).toContain(question));
|
|
}
|
|
|
|
// Builder mode questions
|
|
test('contains builder brainstorming questions', () => {
|
|
expect(content).toContain('coolest version');
|
|
expect(content).toContain('delightful');
|
|
});
|
|
|
|
// Intrapreneurship adaptation
|
|
test('contains intrapreneurship adaptation', () => {
|
|
expect(content).toContain('Intrapreneurship');
|
|
});
|
|
|
|
// YC founder discovery engine
|
|
test('contains YC apply CTA with ref tracking', () => {
|
|
expect(content).toContain('ycombinator.com/apply?ref=gstack');
|
|
});
|
|
|
|
test('contains "What I noticed" design doc section', () => {
|
|
expect(content).toContain('What I noticed about how you think');
|
|
});
|
|
|
|
test('contains golden age framing', () => {
|
|
expect(content).toContain('golden age');
|
|
});
|
|
|
|
test('contains Garry Tan personal plea', () => {
|
|
expect(content).toContain('Garry Tan, the creator of GStack');
|
|
});
|
|
|
|
test('contains founder signal synthesis phase', () => {
|
|
expect(content).toContain('Founder Signal Synthesis');
|
|
});
|
|
|
|
test('contains three-tier decision rubric', () => {
|
|
expect(content).toContain('Top tier');
|
|
expect(content).toContain('Middle tier');
|
|
expect(content).toContain('Base tier');
|
|
});
|
|
|
|
test('contains anti-slop examples', () => {
|
|
expect(content).toContain('GOOD:');
|
|
expect(content).toContain('BAD:');
|
|
});
|
|
|
|
test('contains "One more thing" transition beat', () => {
|
|
expect(content).toContain('One more thing');
|
|
});
|
|
|
|
// Operating principles per mode
|
|
test('contains startup operating principles', () => {
|
|
expect(content).toContain('Specificity is the only currency');
|
|
});
|
|
|
|
test('contains builder operating principles', () => {
|
|
expect(content).toContain('Delight is the currency');
|
|
});
|
|
|
|
// Spec Review Loop (Phase 5.5)
|
|
test('contains spec review loop', () => {
|
|
expect(content).toContain('Spec Review Loop');
|
|
});
|
|
|
|
test('contains adversarial review dimensions', () => {
|
|
for (const dim of ['Completeness', 'Consistency', 'Clarity', 'Scope', 'Feasibility']) {
|
|
expect(content).toContain(dim);
|
|
}
|
|
});
|
|
|
|
test('contains subagent dispatch instruction', () => {
|
|
expect(content).toMatch(/Agent.*tool|subagent/i);
|
|
});
|
|
|
|
test('contains max 3 iterations', () => {
|
|
expect(content).toMatch(/3.*iteration|maximum.*3/i);
|
|
});
|
|
|
|
test('contains quality score', () => {
|
|
expect(content).toContain('quality score');
|
|
});
|
|
|
|
test('contains spec review metrics path', () => {
|
|
expect(content).toContain('spec-review.jsonl');
|
|
});
|
|
|
|
test('contains convergence guard', () => {
|
|
expect(content).toMatch(/convergence/i);
|
|
});
|
|
|
|
// Visual Sketch (Phase 4.5)
|
|
test('contains visual sketch section', () => {
|
|
expect(content).toContain('Visual Sketch');
|
|
});
|
|
|
|
test('contains wireframe generation', () => {
|
|
expect(content).toMatch(/wireframe|sketch/i);
|
|
});
|
|
|
|
test('contains DESIGN.md awareness', () => {
|
|
expect(content).toContain('DESIGN.md');
|
|
});
|
|
|
|
test('wireframes render through gstack-render (Aside first)', () => {
|
|
expect(content).toContain('gstack-render.ts');
|
|
expect(content).toContain('--screenshot');
|
|
});
|
|
|
|
test('contains rough aesthetic instruction', () => {
|
|
expect(content).toMatch(/rough|hand-drawn/i);
|
|
});
|
|
});
|
|
|
|
describe('investigate skill structure', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'investigate', 'SKILL.md'), 'utf-8');
|
|
for (const section of ['Iron Law', 'Root Cause', 'Pattern Analysis', 'Hypothesis',
|
|
'DEBUG REPORT', '3-strike', 'BLOCKED']) {
|
|
test(`contains ${section}`, () => expect(content).toContain(section));
|
|
}
|
|
});
|
|
|
|
// Contributor mode was removed in v0.13.10.0 — replaced by operational self-improvement.
|
|
// Tests for contributor mode preamble structure are no longer applicable.
|
|
|
|
describe('Enum & Value Completeness in review checklist', () => {
|
|
const checklist = fs.readFileSync(path.join(ROOT, 'review', 'checklist.md'), 'utf-8');
|
|
|
|
test('checklist has Enum & Value Completeness section', () => {
|
|
expect(checklist).toContain('Enum & Value Completeness');
|
|
});
|
|
|
|
test('Enum & Value Completeness is classified as CRITICAL', () => {
|
|
// It should appear under Pass 1 — CRITICAL, not Pass 2
|
|
const pass1Start = checklist.indexOf('### Pass 1');
|
|
const pass2Start = checklist.indexOf('### Pass 2');
|
|
const enumStart = checklist.indexOf('Enum & Value Completeness');
|
|
expect(enumStart).toBeGreaterThan(pass1Start);
|
|
expect(enumStart).toBeLessThan(pass2Start);
|
|
});
|
|
|
|
test('Enum & Value Completeness mentions tracing through consumers', () => {
|
|
expect(checklist).toContain('Trace it through every consumer');
|
|
expect(checklist).toContain('case');
|
|
expect(checklist).toContain('allowlist');
|
|
});
|
|
|
|
test('Enum & Value Completeness covers loosened input acceptance and stale user-facing strings', () => {
|
|
const start = checklist.indexOf('#### Enum & Value Completeness');
|
|
const section = checklist.slice(start, checklist.indexOf('### Pass 2', start));
|
|
expect(section).toContain('loosens what an input accepts');
|
|
expect(section).toContain('Loosened acceptance breaks unchanged consumers');
|
|
const maintainability = fs.readFileSync(path.join(ROOT, 'review', 'specialists', 'maintainability.md'), 'utf-8');
|
|
const stale = maintainability.slice(maintainability.indexOf('### Stale Comments & Docstrings'));
|
|
expect(stale.slice(0, stale.indexOf('\n### ', 5))).toContain('User-facing strings (errors, toasts, labels) whose guarding condition changed');
|
|
});
|
|
|
|
test('Enum & Value Completeness is in the severity classification as CRITICAL', () => {
|
|
const gateSection = checklist.slice(checklist.indexOf('## Severity Classification'));
|
|
// The ASCII art has CRITICAL on the left and INFORMATIONAL on the right
|
|
// Enum & Value Completeness should appear on a line with the CRITICAL tree (├─ or └─)
|
|
const enumLine = gateSection.split('\n').find(l => l.includes('Enum & Value Completeness'));
|
|
expect(enumLine).toBeDefined();
|
|
// It's on the left (CRITICAL) side — starts with ├─ or └─
|
|
expect(enumLine!.trimStart().startsWith('├─') || enumLine!.trimStart().startsWith('└─')).toBe(true);
|
|
});
|
|
|
|
test('Fix-First Heuristic exists in checklist and is referenced by review + ship', () => {
|
|
expect(checklist).toContain('## Fix-First Heuristic');
|
|
expect(checklist).toContain('AUTO-FIX');
|
|
expect(checklist).toContain('ASK');
|
|
|
|
const reviewSkill = fs.readFileSync(path.join(ROOT, 'review/SKILL.md'), 'utf-8');
|
|
const shipSkill = readShipUnion();
|
|
expect(reviewSkill).toContain('AUTO-FIX');
|
|
expect(reviewSkill).toContain('[AUTO-FIXED]');
|
|
expect(shipSkill).toContain('AUTO-FIX');
|
|
expect(shipSkill).toContain('[AUTO-FIXED]');
|
|
});
|
|
});
|
|
|
|
// --- Completeness Principle spot-check ---
|
|
|
|
describe('Completeness Principle in generated SKILL.md files', () => {
|
|
const skillsWithPreamble = [
|
|
'qa/SKILL.md',
|
|
'qa-only/SKILL.md',
|
|
'ship/SKILL.md', 'review/SKILL.md',
|
|
'plan-ceo-review/SKILL.md', 'plan-eng-review/SKILL.md',
|
|
'retro/SKILL.md',
|
|
'plan-design-review/SKILL.md',
|
|
'design-review/SKILL.md',
|
|
'design-consultation/SKILL.md',
|
|
'document-release/SKILL.md',
|
|
// CSO reports complete/partial/not-assessed from its own evidence contract
|
|
// and must not inherit the shared numerical completeness rubric.
|
|
];
|
|
|
|
for (const skill of skillsWithPreamble) {
|
|
test(`${skill} contains Completeness Principle section`, () => {
|
|
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
|
|
expect(content).toContain('Completeness Principle');
|
|
expect(content).toContain('Boil the Ocean');
|
|
});
|
|
}
|
|
|
|
test('Completeness Principle keeps compact scoring guidance in tier 2+ skills', () => {
|
|
// CSO is intentionally exempt; use a regular tier 2+ PREAMBLE consumer.
|
|
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('Completeness: X/10');
|
|
expect(content).toContain('10 = all edge cases');
|
|
expect(content).toContain('Note: options differ in kind, not coverage');
|
|
expect(content).toContain('Do not fabricate scores');
|
|
});
|
|
});
|
|
|
|
// --- Part 7: Planted-bug fixture validation (A4) ---
|
|
|
|
describe('Planted-bug fixture validation', () => {
|
|
test('qa-eval ground truth has exactly 5 planted bugs', () => {
|
|
const groundTruth = JSON.parse(
|
|
fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'qa-eval-ground-truth.json'), 'utf-8')
|
|
);
|
|
expect(groundTruth.bugs).toHaveLength(5);
|
|
expect(groundTruth.total_bugs).toBe(5);
|
|
});
|
|
|
|
test('qa-eval-spa ground truth has exactly 5 planted bugs', () => {
|
|
const groundTruth = JSON.parse(
|
|
fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'qa-eval-spa-ground-truth.json'), 'utf-8')
|
|
);
|
|
expect(groundTruth.bugs).toHaveLength(5);
|
|
expect(groundTruth.total_bugs).toBe(5);
|
|
});
|
|
|
|
test('qa-eval-checkout ground truth has exactly 5 planted bugs', () => {
|
|
const groundTruth = JSON.parse(
|
|
fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'qa-eval-checkout-ground-truth.json'), 'utf-8')
|
|
);
|
|
expect(groundTruth.bugs).toHaveLength(5);
|
|
expect(groundTruth.total_bugs).toBe(5);
|
|
});
|
|
|
|
test('qa-eval.html contains the planted bugs', () => {
|
|
const html = fs.readFileSync(path.join(ROOT, 'browse', 'test', 'fixtures', 'qa-eval.html'), 'utf-8');
|
|
// BUG 1: broken link
|
|
expect(html).toContain('/nonexistent-404-page');
|
|
// BUG 2: disabled submit
|
|
expect(html).toContain('disabled');
|
|
// BUG 3: overflow
|
|
expect(html).toContain('overflow: hidden');
|
|
// BUG 4: missing alt
|
|
expect(html).toMatch(/<img[^>]*src="\/logo\.png"[^>]*>/);
|
|
expect(html).not.toMatch(/<img[^>]*src="\/logo\.png"[^>]*alt=/);
|
|
// BUG 5: console error
|
|
expect(html).toContain("Cannot read properties of undefined");
|
|
});
|
|
|
|
test('review-eval-vuln.rb contains expected vulnerability patterns', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'review-eval-vuln.rb'), 'utf-8');
|
|
expect(content).toContain('params[:id]');
|
|
expect(content).toContain('update_column');
|
|
});
|
|
});
|
|
|
|
// --- CEO review mode validation ---
|
|
|
|
describe('CEO review mode validation', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
|
|
|
|
test('has all four CEO review modes defined', () => {
|
|
const modes = ['SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'HOLD SCOPE', 'SCOPE REDUCTION'];
|
|
for (const mode of modes) {
|
|
expect(content).toContain(mode);
|
|
}
|
|
});
|
|
|
|
test('has CEO plan persistence step', () => {
|
|
expect(content).toContain('ceo-plans');
|
|
expect(content).toContain('status: ACTIVE');
|
|
});
|
|
|
|
test('has docs/designs promotion section', () => {
|
|
// Carved (v2 plan Phase B): the promotion block moved into the review section.
|
|
const union = readSkillUnion('plan-ceo-review');
|
|
expect(union).toContain('docs/designs');
|
|
expect(union).toContain('PROMOTED');
|
|
});
|
|
|
|
test('mode quick reference has four columns', () => {
|
|
expect(content).toContain('EXPANSION');
|
|
expect(content).toContain('SELECTIVE');
|
|
expect(content).toContain('HOLD SCOPE');
|
|
expect(content).toContain('REDUCTION');
|
|
});
|
|
|
|
// Skill chaining (benefits-from)
|
|
test('contains prerequisite skill offer for office-hours', () => {
|
|
expect(content).toContain('Prerequisite Skill Offer');
|
|
expect(content).toContain('/office-hours');
|
|
});
|
|
|
|
test('contains mid-session detection', () => {
|
|
expect(content).toContain('Mid-session detection');
|
|
expect(content).toMatch(/still figuring out|seems lost/i);
|
|
});
|
|
|
|
// Spec review on CEO plans
|
|
test('contains spec review loop for CEO plan documents', () => {
|
|
expect(content).toContain('Spec Review Loop');
|
|
});
|
|
});
|
|
|
|
// --- gstack-slug helper ---
|
|
|
|
describe('gstack-slug', () => {
|
|
const SLUG_BIN = path.join(ROOT, 'bin', 'gstack-slug');
|
|
usePrivateStateRoot();
|
|
|
|
test('binary exists and is executable', () => {
|
|
expect(fs.existsSync(SLUG_BIN)).toBe(true);
|
|
const stat = fs.statSync(SLUG_BIN);
|
|
expect(stat.mode & 0o111).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('outputs SLUG and BRANCH lines in a git repo', () => {
|
|
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, env: { ...process.env }, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
expect(result.exitCode).toBe(0);
|
|
const output = result.stdout.toString();
|
|
expect(output).toContain('SLUG=');
|
|
expect(output).toContain('BRANCH=');
|
|
});
|
|
|
|
test('SLUG does not contain forward slashes', () => {
|
|
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, env: { ...process.env }, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
const slug = result.stdout.toString().match(/SLUG=(.*)/)?.[1] ?? '';
|
|
expect(slug).not.toContain('/');
|
|
expect(slug.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('BRANCH does not contain forward slashes', () => {
|
|
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, env: { ...process.env }, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
const branch = result.stdout.toString().match(/BRANCH=(.*)/)?.[1] ?? '';
|
|
expect(branch).not.toContain('/');
|
|
expect(branch.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('output is eval-compatible (KEY=VALUE format)', () => {
|
|
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, env: { ...process.env }, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
const lines = result.stdout.toString().trim().split('\n');
|
|
expect(lines.length).toBe(2);
|
|
expect(lines[0]).toMatch(/^SLUG=.+/);
|
|
expect(lines[1]).toMatch(/^BRANCH=.+/);
|
|
});
|
|
|
|
test('output values contain only safe characters (no shell metacharacters)', () => {
|
|
const result = Bun.spawnSync([SLUG_BIN], { cwd: ROOT, env: { ...process.env }, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
const slug = result.stdout.toString().match(/SLUG=(.*)/)?.[1] ?? '';
|
|
const branch = result.stdout.toString().match(/BRANCH=(.*)/)?.[1] ?? '';
|
|
// Only alphanumeric, dot, dash, underscore are allowed (#133)
|
|
expect(slug).toMatch(/^[a-zA-Z0-9._-]+$/);
|
|
expect(branch).toMatch(/^[a-zA-Z0-9._-]+$/);
|
|
});
|
|
test('eval sets variables under bash with set -euo pipefail', () => {
|
|
const result = Bun.spawnSync(
|
|
['bash', '-c', 'set -euo pipefail; eval "$(./bin/gstack-slug 2>/dev/null)"; echo "SLUG=$SLUG"; echo "BRANCH=$BRANCH"'],
|
|
{ cwd: ROOT, env: { ...process.env }, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }
|
|
);
|
|
expect(result.exitCode).toBe(0);
|
|
const output = result.stdout.toString();
|
|
expect(output).toMatch(/^SLUG=.+/m);
|
|
expect(output).toMatch(/^BRANCH=.+/m);
|
|
});
|
|
|
|
test('no templates or bin scripts use source process substitution for gstack-slug', () => {
|
|
// Scan project sources, including new untracked templates, without walking
|
|
// ignored caches, symlinked installations or private evaluation worktrees.
|
|
const result = Bun.spawnSync(
|
|
['git', 'ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', '*.tmpl', 'bin/gstack-review-*'],
|
|
{ cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 5_000 },
|
|
);
|
|
expect(result.exitCode, result.stderr.toString()).toBe(0);
|
|
const files = [...new Set(result.stdout.toString().split('\0').filter(Boolean))];
|
|
expect(files.length).toBeGreaterThan(0);
|
|
const violations = files.filter(file => /source <\(.*gstack-slug/.test(fs.readFileSync(path.join(ROOT, file), 'utf8')));
|
|
expect(violations).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// --- Test Bootstrap validation ---
|
|
|
|
describe('Test Bootstrap ({{TEST_BOOTSTRAP}}) integration', () => {
|
|
test('TEST_BOOTSTRAP resolver produces valid content', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'ship/sections/tests.md'), 'utf8');
|
|
expect(content).toContain('Test Framework Bootstrap');
|
|
expect(content).toContain('RUNTIME:ruby');
|
|
expect(content).toContain('RUNTIME:node');
|
|
expect(content).toContain('RUNTIME:python');
|
|
expect(content).toContain('no-test-bootstrap');
|
|
expect(content).toContain('BOOTSTRAP_DECLINED');
|
|
});
|
|
|
|
test('TEST_BOOTSTRAP appears in qa/SKILL.md', () => {
|
|
const content = readSkillUnion('qa');
|
|
expect(content).toContain('Test Framework Bootstrap');
|
|
expect(content).toContain('TESTING.md');
|
|
expect(content).toContain('CLAUDE.md');
|
|
});
|
|
|
|
test('TEST_BOOTSTRAP appears in ship/SKILL.md', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Test Framework Bootstrap');
|
|
expect(content).toContain('Step 4');
|
|
});
|
|
|
|
test('TEST_BOOTSTRAP appears in design-review/SKILL.md', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('Test Framework Bootstrap');
|
|
});
|
|
|
|
test('TEST_BOOTSTRAP does NOT appear in qa-only/SKILL.md', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'qa-only', 'SKILL.md'), 'utf-8');
|
|
expect(content).not.toContain('Test Framework Bootstrap');
|
|
// But should have the recommendation note
|
|
expect(content).toContain('No test framework detected');
|
|
expect(content).toContain('Run `/qa` to bootstrap');
|
|
});
|
|
|
|
test('bootstrap includes framework knowledge table', () => {
|
|
const content = readSkillUnion('qa');
|
|
expect(content).toContain('vitest');
|
|
expect(content).toContain('minitest');
|
|
expect(content).toContain('pytest');
|
|
expect(content).toContain('cargo test');
|
|
expect(content).toContain('phpunit');
|
|
expect(content).toContain('ExUnit');
|
|
});
|
|
|
|
test('bootstrap includes CI/CD pipeline generation', () => {
|
|
const content = readSkillUnion('qa');
|
|
expect(content).toContain('.github/workflows/test.yml');
|
|
expect(content).toContain('GitHub Actions');
|
|
});
|
|
|
|
test('bootstrap includes first real tests step', () => {
|
|
const content = readSkillUnion('qa');
|
|
expect(content).toContain('First real tests');
|
|
expect(content).toContain('git log --since=30.days');
|
|
expect(content).toContain('Prioritize by risk');
|
|
});
|
|
|
|
test('bootstrap includes vibe coding philosophy', () => {
|
|
const content = readSkillUnion('qa');
|
|
expect(content).toContain('vibe coding');
|
|
expect(content).toContain('100% test coverage');
|
|
});
|
|
|
|
test('WebSearch is in allowed-tools for qa, ship, design-review', () => {
|
|
const qa = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
|
const ship = readShipUnion();
|
|
const qaDesign = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
|
|
expect(qa).toContain('WebSearch');
|
|
expect(ship).toContain('WebSearch');
|
|
expect(qaDesign).toContain('WebSearch');
|
|
});
|
|
});
|
|
|
|
// --- Phase 8e.5 regression test validation ---
|
|
|
|
describe('Phase 8e.5 regression test generation', () => {
|
|
test('qa/SKILL.md contains Phase 8e.5', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('8e.5. Regression Test');
|
|
expect(content).toContain('test(qa): regression test');
|
|
expectMentions(content, [['do not', 'test-only', 'commits']], 'content');
|
|
});
|
|
|
|
test('qa/SKILL.md Rule 13 is amended for regression tests', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
|
expectMentions(content, [['only', 'codification', 'authorized']], 'content');
|
|
expectMentions(content, [['never', 'configuration', 'existing']], 'content');
|
|
expect(content.indexOf('### 8a.5. Regression test before repair')).toBeLessThan(content.indexOf('### 8b. Fix'));
|
|
expectMentions(content, [['before', 'detected', 'command']], 'content');
|
|
const exploratory = fs.readFileSync(path.join(ROOT, 'qa', 'sections', 'exploratory.md'), 'utf-8').replace(/\s+/g, ' ');
|
|
expectMentions(exploratory, [['before', 'regression', 'verified']], 'exploratory');
|
|
expectMentions(exploratory, [['before', 'command/request', 'failing']], 'exploratory');
|
|
expectMentions(exploratory, [['not', 'regression', 'another']], 'exploratory');
|
|
});
|
|
|
|
test('design-review has CSS-aware Phase 8e.5 variant', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('8e.5. Regression Test (design-review variant)');
|
|
expect(content).toContain('CSS-only');
|
|
expect(content).toContain('test(design): regression test');
|
|
});
|
|
|
|
test('regression test includes full attribution comment format', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('// Regression: ISSUE-NNN');
|
|
expect(content).toContain('// Found by /qa on');
|
|
expect(content).toContain('// Report: .gstack/qa-reports/');
|
|
});
|
|
|
|
test('regression test uses auto-incrementing names', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('auto-incrementing');
|
|
expect(content).toContain('max number + 1');
|
|
});
|
|
});
|
|
|
|
// --- Step 3.4 coverage audit validation ---
|
|
|
|
describe('Step 3.4 test coverage audit', () => {
|
|
test('ship/SKILL.md contains Step 7', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Step 7: Test Coverage Audit');
|
|
// The coverage diagram collapses code-path and user-flow counts onto one
|
|
// summary line. Verify that summary is present (labels are stable).
|
|
expect(content).toContain('Code paths:');
|
|
});
|
|
|
|
test('Step 3.4 includes quality scoring rubric', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('★★★');
|
|
expect(content).toContain('★★');
|
|
expect(content).toContain('edge cases AND error paths');
|
|
expect(content).toContain('happy path only');
|
|
});
|
|
|
|
test('Step 3.4 includes before/after test count', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Count test files before');
|
|
expect(content).toContain('Count test files after');
|
|
});
|
|
|
|
test('ship PR body includes Test Coverage section', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('## Test Coverage');
|
|
});
|
|
|
|
test('ship rules include test generation rule', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Step 7 generates coverage tests');
|
|
expect(content).toContain('Never commit failing tests');
|
|
});
|
|
|
|
test('Step 3.4 states the value-based coverage goal', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Coverage goal: every changed behavior is protected by a test that would catch a real regression. Test count is not a goal.');
|
|
});
|
|
|
|
test('Step 3.4 traces actual codepaths, not just syntax', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Trace every codepath');
|
|
expect(content).toContain('Trace data flow');
|
|
expect(content).toContain('Diagram the execution');
|
|
});
|
|
|
|
test('Step 3.4 maps user flows and interaction edge cases', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Map user flows');
|
|
expect(content).toContain('Interaction edge cases');
|
|
expect(content).toContain('Double-click');
|
|
expect(content).toContain('Navigate away');
|
|
expect(content).toContain('Error states the user can see');
|
|
expect(content).toContain('Empty/zero/boundary states');
|
|
});
|
|
|
|
test('Step 3.4 diagram includes user-flow coverage summary', () => {
|
|
const content = readShipUnion();
|
|
// The diagram was compressed from separate CODE PATH COVERAGE / USER FLOW
|
|
// COVERAGE section headers into a single summary line. Assert on the
|
|
// labels that still appear on that summary line.
|
|
expect(content).toContain('Code paths:');
|
|
expect(content).toContain('User flows:');
|
|
});
|
|
});
|
|
|
|
// --- Ship step numbering regression guard ---
|
|
|
|
describe('ship step numbering', () => {
|
|
// Allowed sub-steps that are resolver-generated and intentionally nested:
|
|
// 0.9 (Apple target detection — MUST precede Step 1's branch gate, R2-pinned
|
|
// by test/ship-apple-gate.test.ts), 8.1 (Plan Verification), 8.2 (Scope
|
|
// Drift), 9.1 (Review Army), 9.2 (Findings Merge), 9.3 (Cross-review dedup),
|
|
// 9.4 (Fix-First and persistence), 15.0 (WIP context), 15.1 (Bisectable commits),
|
|
// 15.2 (safe optional WIP consolidation), 6.5 (early Greptile PR, which must
|
|
// follow the free tests and precede the reviews it runs in parallel with).
|
|
const ALLOWED_SUBSTEPS = new Set(['0.9', '6.5', '8.1', '8.2', '9.1', '9.2', '9.3', '9.4', '11.5', '14.5', '15.0', '15.1', '15.2']);
|
|
|
|
test('ship/SKILL.md.tmpl contains no unexpected fractional step numbers', () => {
|
|
const tmpl = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md.tmpl'), 'utf-8');
|
|
// Match "Step X.Y" where X.Y is a decimal step reference (e.g., "Step 3.47", "Step 8.1")
|
|
const matches = Array.from(tmpl.matchAll(/Step (\d+\.\d+)/g));
|
|
const violations = matches
|
|
.map((m) => m[1])
|
|
.filter((n) => !ALLOWED_SUBSTEPS.has(n));
|
|
if (violations.length > 0) {
|
|
const unique = Array.from(new Set(violations)).sort();
|
|
throw new Error(
|
|
`ship/SKILL.md.tmpl contains fractional step numbers that are not in the allowed sub-step list.\n` +
|
|
` Found: ${unique.join(', ')}\n` +
|
|
` Allowed sub-steps: ${Array.from(ALLOWED_SUBSTEPS).sort().join(', ')}\n` +
|
|
` Fix: use clean integer step numbers (1-20), or add to ALLOWED_SUBSTEPS if intentional.`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('ship/SKILL.md main headings use clean integer step numbers', () => {
|
|
const skill = readShipUnion();
|
|
// Headings like "## Step 7: Test Coverage Audit" — NOT sub-steps like "## Step 8.1:"
|
|
const headings = Array.from(skill.matchAll(/^## Step (\d+(?:\.\d+)?):/gm)).map(
|
|
(m) => m[1]
|
|
);
|
|
const fractional = headings.filter((n) => n.includes('.'));
|
|
const unexpected = fractional.filter((n) => !ALLOWED_SUBSTEPS.has(n));
|
|
expect(unexpected).toEqual([]);
|
|
expect(headings.filter((n) => n === '11.5')).toHaveLength(1);
|
|
});
|
|
|
|
test('review/SKILL.md step numbers unchanged (regression guard for resolver conditionals)', () => {
|
|
// Carved skill: Step 4.5 lives in sections/review-army.md and Step 4.8 in
|
|
// sections/adversarial.md — read the skeleton+sections union.
|
|
const skill = readSkillUnion('review');
|
|
// /review uses its own fractional numbering: 1.5, 2.5, 4.5, 4.8, 5.8
|
|
// If the ship-side renumber accidentally touched the review-side of resolver conditionals,
|
|
// these would vanish. This test catches that.
|
|
expect(skill).toContain('## Step 1.5: Scope Drift Detection');
|
|
expect(skill).toContain('## Step 4.5: Review Army');
|
|
expect(skill).toContain('## Step 4.8: Adversarial review');
|
|
});
|
|
});
|
|
|
|
// --- Retro test health validation ---
|
|
|
|
describe('Retro test health tracking', () => {
|
|
// The inline git pipelines moved into bin/gstack-retro-metrics (retro
|
|
// token-reduction wave); the skill interprets its labeled METRIC lines.
|
|
test('gstack-retro-metrics gathers the test health data', () => {
|
|
const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-retro-metrics'), 'utf-8');
|
|
expect(script).toContain('TEST_FILES_TOTAL');
|
|
expect(script).toContain('REGRESSION_TEST_COMMITS');
|
|
expect(script).toContain('TEST_FILES_CHANGED');
|
|
// The historical grep targets survive the script absorption.
|
|
expect(script).toContain('test(qa):');
|
|
expect(script).toContain('test(design):');
|
|
expect(script).toContain('git ls-files');
|
|
});
|
|
|
|
test('retro skill interprets the test-health metric lines', () => {
|
|
// Template source, not the generated render: the pin must hold across the
|
|
// regen boundary (the generated file follows the template at gen time).
|
|
const content = fs.readFileSync(path.join(ROOT, 'retro', 'SKILL.md.tmpl'), 'utf-8');
|
|
expect(content).toContain('TEST_FILES_TOTAL');
|
|
expect(content).toContain('REGRESSION_TEST_COMMITS');
|
|
});
|
|
|
|
test('retro/SKILL.md has Test Health metrics row', () => {
|
|
const content = readSkillUnion('retro');
|
|
expect(content).toContain('Test Health');
|
|
expect(content).toContain('N test files');
|
|
expect(content).toContain('M changed this period');
|
|
expect(content).toContain('K regression test commits');
|
|
});
|
|
|
|
test('retro/SKILL.md has Test Health narrative section', () => {
|
|
// Carved: the narrative report format lives in sections/report-format.md.
|
|
const content = readSkillUnion('retro');
|
|
expect(content).toContain('### Test Health');
|
|
expect(content).toContain('Total test files');
|
|
expect(content).toContain('vibe coding safe');
|
|
});
|
|
|
|
test('retro JSON schema includes test_health field', () => {
|
|
const content = readSkillUnion('retro');
|
|
expect(content).toContain('test_health');
|
|
expect(content).toContain('total_test_files');
|
|
expect(content).toContain('regression_test_commits');
|
|
});
|
|
});
|
|
|
|
// --- QA report template regression tests section ---
|
|
|
|
describe('QA report template', () => {
|
|
test('qa-report-template.md has Regression Tests section', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'qa', 'templates', 'qa-report-template.md'), 'utf-8');
|
|
expect(content).toContain('## Regression Tests');
|
|
expect(content).toContain('committed / deferred / skipped');
|
|
expect(content).toContain('### Deferred Tests');
|
|
expect(content).toContain('**Precondition:**');
|
|
});
|
|
});
|
|
|
|
// --- Codex skill validation ---
|
|
|
|
describe('Codex skill', () => {
|
|
test('codex/SKILL.md exists and has correct frontmatter', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('name: codex');
|
|
expect(content).toContain('version: 1.0.0');
|
|
expect(content).toContain('allowed-tools:');
|
|
});
|
|
|
|
// Carved skill (T9): the three mode bodies live in codex/sections/
|
|
// {review,challenge,consult}-mode.md. Content pins read the union; the
|
|
// mode DISPATCH must stay in the always-loaded skeleton.
|
|
test('codex union contains all three modes; skeleton keeps the dispatch', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('Step 2A: Review Mode');
|
|
expect(content).toContain('Step 2B: Challenge');
|
|
expect(content).toContain('Step 2C: Consult Mode');
|
|
const skeleton = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
|
expect(skeleton).toContain('## Step 1: Detect mode');
|
|
expect(skeleton).toMatch(/mutually exclusive/i);
|
|
});
|
|
|
|
test('codex union contains gate verdict logic', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('[P1]');
|
|
expect(content).toContain('GATE: PASS');
|
|
expect(content).toContain('GATE: FAIL');
|
|
});
|
|
|
|
test('codex union contains session continuity', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('codex-session-id');
|
|
expect(content).toContain('codex exec resume');
|
|
});
|
|
|
|
test('codex resume command only uses resume-supported flags', () => {
|
|
const content = readSkillUnion('codex');
|
|
const match = content.match(/codex exec resume[^\n]+/);
|
|
expect(match).not.toBeNull();
|
|
const resumeCommand = match![0];
|
|
expect(resumeCommand).not.toContain(' -C ');
|
|
expect(resumeCommand).not.toMatch(/ -s /);
|
|
// resume takes the sandbox only as config; the probe's select-model reports it (read-only by default).
|
|
expect(resumeCommand).toContain('-c "sandbox_mode=\\"${_CODEX_SANDBOX_MODE:?}\\""');
|
|
});
|
|
|
|
test('codex union contains cost tracking', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('tokens used');
|
|
expect(content).toContain('Est. cost');
|
|
});
|
|
|
|
test('codex union contains cross-model comparison', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('CROSS-MODEL ANALYSIS');
|
|
expect(content).toContain('Agreement rate');
|
|
});
|
|
|
|
test('codex union contains review log persistence', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('codex-review');
|
|
expect(content).toContain('gstack-review-log');
|
|
});
|
|
|
|
test('codex uses command -v for binary discovery, not hardcoded path', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('command -v codex');
|
|
expect(content).not.toContain('/opt/homebrew/bin/codex');
|
|
// Defensive: catch any future regression that reintroduces `which codex`,
|
|
// which fails in environments where `which` isn't on PATH (some Windows
|
|
// shells, BusyBox-only containers). #1197.
|
|
expect(content).not.toContain('which codex');
|
|
});
|
|
|
|
test('codex/SKILL.md contains error handling for missing binary and auth', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('NOT_FOUND');
|
|
expect(content).toContain('codex login');
|
|
});
|
|
|
|
test('codex union uses mktemp for temp files', () => {
|
|
const content = readSkillUnion('codex');
|
|
expect(content).toContain('mktemp');
|
|
});
|
|
|
|
test('codex JSON stream parser uses portable Python discovery', () => {
|
|
// The JSONL parsers live in the challenge + consult mode sections; sweep
|
|
// the tmpl union and the rendered union so neither side drifts.
|
|
const sectionsDir = path.join(ROOT, 'codex', 'sections');
|
|
const tmplUnion = ['codex/SKILL.md.tmpl']
|
|
.map((rel) => fs.readFileSync(path.join(ROOT, rel), 'utf-8'))
|
|
.concat(
|
|
fs.readdirSync(sectionsDir).sort()
|
|
.filter((f) => f.endsWith('.md.tmpl'))
|
|
.map((f) => fs.readFileSync(path.join(sectionsDir, f), 'utf-8')),
|
|
)
|
|
.join('\n');
|
|
|
|
for (const content of [tmplUnion, readSkillUnion('codex')]) {
|
|
expect(content).toContain('PYTHON_CMD=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)');
|
|
expect(content).toContain('PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c');
|
|
expect(content).not.toContain('PYTHONUNBUFFERED=1 python3 -u -c');
|
|
}
|
|
});
|
|
|
|
test('adversarial review in /review always runs both passes', () => {
|
|
// Carved skill: the Step 4.8 adversarial body lives in sections/adversarial.md.
|
|
const content = readSkillUnion('review');
|
|
expect(content).toContain('Adversarial review (always-on)');
|
|
// Always-on: both Claude and Codex adversarial
|
|
expect(content).toContain('Claude adversarial subagent (always runs)');
|
|
expect(content).toContain('Codex adversarial challenge (runs whenever');
|
|
// Claude adversarial subagent dispatch
|
|
expect(content).toContain('Agent tool');
|
|
expect(content).toContain('FIXABLE');
|
|
expect(content).toContain('INVESTIGATE');
|
|
// Probe-based availability via the shared codexPreflight() (install + auth)
|
|
expect(content).toContain('CODEX_MODE');
|
|
expect(content).toContain('command -v codex'); // install check kept literal
|
|
// codex_reviews=disabled gates Codex passes only; Claude adversarial still runs
|
|
expectMentions(content, [['only', 'disabled', 'passes']], 'content');
|
|
// Review log
|
|
expect(content).toContain('adversarial-review');
|
|
expect(content).toContain('reasoning_effort="high"');
|
|
expect(content).toContain('ADVERSARIAL REVIEW SYNTHESIS');
|
|
// Large diff structured review still gated
|
|
expectMentions(content, [['only', 'structured', 'review']], 'content');
|
|
expect(content).toContain('200');
|
|
});
|
|
|
|
test('adversarial review in /ship always runs both passes', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Adversarial review (always-on)');
|
|
expect(content).toContain('adversarial-review');
|
|
expect(content).toContain('reasoning_effort="high"');
|
|
expect(content).toContain('Investigate and fix');
|
|
expect(content).toContain('Claude adversarial subagent (always runs)');
|
|
});
|
|
|
|
test('scope drift detection in /review and /ship', () => {
|
|
const reviewContent = readSkillUnion('review');
|
|
const shipContent = readShipUnion();
|
|
// Both should contain scope drift from the shared resolver
|
|
for (const content of [reviewContent, shipContent]) {
|
|
expect(content).toContain('Scope Check:');
|
|
expect(content).toContain('DRIFT DETECTED');
|
|
expect(content).toContain('SCOPE CREEP');
|
|
expect(content).toContain('MISSING REQUIREMENTS');
|
|
expect(content).toContain('stated intent');
|
|
}
|
|
});
|
|
|
|
test('codex-host ship/review preserve adversarial review with a Claude outside voice', () => {
|
|
for (const skill of ['ship', 'review']) {
|
|
const content = readCodexSkillUnion(skill);
|
|
expect(content).not.toMatch(/codex\s+(?:exec|review)\s/);
|
|
expect(content).toContain('gstack-claude-code');
|
|
expect(content).toContain('codex_reviews');
|
|
expect(content).toContain('adversarial-review');
|
|
expect(content).toContain('Investigate and fix');
|
|
}
|
|
});
|
|
|
|
test('codex integration in /plan-eng-review offers plan critique', () => {
|
|
// Carved skill: the Codex outside-voice plan critique lives in
|
|
// sections/review-sections.md — read the skeleton+sections union. (The
|
|
// skeleton alone used to match "Codex" only via an inline-bash comment
|
|
// that the gstack-skill-start consolidation removed.)
|
|
const content = readSkillUnion('plan-eng-review');
|
|
expect(content).toContain('Codex');
|
|
expect(content).toContain('codex exec');
|
|
});
|
|
|
|
// D5 regression guard: the Codex outside voice is default-on, not opt-in. A future
|
|
// gen-skill-docs change must not silently reintroduce the "Want an outside voice?"
|
|
// AskUserQuestion. The CODEX_PLAN_REVIEW content renders into each skill's
|
|
// sections/review-sections.md (the skeleton points at it). plan-design-review uses
|
|
// DESIGN_OUTSIDE_VOICES, not CODEX_PLAN_REVIEW, so it is excluded here.
|
|
test('plan reviews run the Codex outside voice default-on (no opt-in question)', () => {
|
|
for (const skill of ['plan-eng-review', 'plan-ceo-review', 'plan-devex-review']) {
|
|
const content = fs.readFileSync(
|
|
path.join(ROOT, skill, 'sections', 'review-sections.md'), 'utf-8');
|
|
expect(content).not.toContain('Want an outside voice');
|
|
expect(content).toContain('CODEX_MODE');
|
|
expect(content).toContain('command -v codex'); // preflight install check (e2e relies on it)
|
|
}
|
|
});
|
|
|
|
test('/document-release includes the default-on Codex documentation review', () => {
|
|
// The doc-review renders into the carved release-body section (kept out of the
|
|
// always-loaded skeleton to respect the skeleton-byte budget).
|
|
const content = fs.readFileSync(
|
|
path.join(ROOT, 'document-release', 'sections', 'release-body.md'), 'utf-8');
|
|
expect(content).toContain('Codex Documentation Review (default-on)');
|
|
expect(content).toContain('CODEX_MODE');
|
|
expect(content).toContain('codex-doc-review');
|
|
});
|
|
|
|
test('codex-host document-release runs Claude Code and keeps the historical log identifier', () => {
|
|
const content = readCodexSkillUnion('document-release');
|
|
expect(content).toContain('Claude Code');
|
|
expect(content).toContain('gstack-claude-code');
|
|
expect(content).toContain('codex-doc-review');
|
|
expect(content).not.toMatch(/codex\s+(?:exec|review)\s/);
|
|
});
|
|
|
|
test('codex review invocations avoid the prompt plus --base argument shape', () => {
|
|
// The real invariant is "never pass a positional [PROMPT] together with a
|
|
// scope flag" — the CLI rejects that combination at argv parse time
|
|
// (#1428, #1479). Two different shapes satisfy it, and these files have
|
|
// diverged on which one they use:
|
|
//
|
|
// scoped — `codex review --base <base>` with NO prompt argument. The
|
|
// scope comes from the CLI, which is the only thing that actually sets
|
|
// it. This is what all three files now use.
|
|
// broken — prompt-only `codex review "<text>"` describing the diff
|
|
// range in prose. This parses, but the CLI falls back to *uncommitted
|
|
// working-tree* scope, so the review silently covers the wrong changes.
|
|
//
|
|
// The old assertion banned the substring `--base <base> -c '...'`, which
|
|
// the correct scoped form also contains — it could not tell the two apart,
|
|
// so it effectively banned the fix.
|
|
for (const rel of ['codex/SKILL.md', 'review/SKILL.md', 'ship/SKILL.md']) {
|
|
// ship's AND review's codex commands moved into sections/adversarial.md;
|
|
// codex's own scoped invocation lives in sections/review-mode.md (T9 carve).
|
|
const content = rel === 'ship/SKILL.md' ? readShipUnion()
|
|
: rel === 'review/SKILL.md' ? readSkillUnion('review')
|
|
: readSkillUnion('codex');
|
|
expect(content).toMatch(/codex\s+review\s+--base\b/);
|
|
const offending: string[] = [];
|
|
for (const line of content.split('\n')) {
|
|
if (line.includes('`codex review`')) continue;
|
|
const match = line.match(/(?:^|[;&|]\s*|\s)codex\s+review\b(.*)$/);
|
|
if (!match) continue;
|
|
const rest = match[1];
|
|
if (!/--base\b|--commit\b|--uncommitted\b/.test(rest)) continue;
|
|
const beforeFlag = rest.split(/--base\b|--commit\b|--uncommitted\b/)[0].trim();
|
|
// A quoted string or variable expansion before the scope flag is the bug.
|
|
if (/^["'$]|^--\s*["']/.test(beforeFlag)) offending.push(`${rel}: ${line.trim()}`);
|
|
}
|
|
expect(offending).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test('codex review prompts always carry the filesystem boundary (#1503/#1522 regression)', () => {
|
|
// Pre-#1209, the bare `codex review --base` path stripped the filesystem
|
|
// boundary instruction, letting Codex spend tokens reading skill files.
|
|
// #1209's prompt rewrite restored the boundary by routing every default
|
|
// call through a prompt — but routing through a prompt is what breaks the
|
|
// diff scope, so codex/ no longer does that. What this test pins is the
|
|
// boundary TEXT, which must still be present for the paths that do take a
|
|
// prompt (`codex exec` for challenge, consult, and custom review focus).
|
|
// Do NOT "restore" the boundary by putting a prompt argument back on a
|
|
// scoped `codex review` call: that combination fails to parse, and
|
|
// dropping the scope flag to make it parse silently reviews the wrong diff.
|
|
const boundaryLine = /do not read or execute any files under ~\/\.claude\/, ~\/\.agents\/, \.claude\/skills\/, or agents\//i;
|
|
for (const rel of ['codex/SKILL.md', 'review/SKILL.md', 'ship/SKILL.md']) {
|
|
// ship's AND review's codex/adversarial boundary lines moved into sections/adversarial.md.
|
|
const content = rel === 'ship/SKILL.md' ? readShipUnion()
|
|
: rel === 'review/SKILL.md' ? readSkillUnion('review')
|
|
: fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
|
expect(content).toMatch(boundaryLine);
|
|
}
|
|
});
|
|
|
|
test('/review persists a review-log entry for ship readiness', () => {
|
|
const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('"skill":"review"');
|
|
expect(content).toContain('"issues_found":N');
|
|
expect(content).toContain('Persist Eng Review result');
|
|
});
|
|
|
|
test('Review Readiness Dashboard includes Adversarial Review row', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Adversarial');
|
|
expect(content).toContain('codex-review');
|
|
});
|
|
});
|
|
|
|
// --- Trigger phrase validation ---
|
|
|
|
describe('Skill trigger phrases', () => {
|
|
// Skills that must have "Use when" trigger phrases in their description.
|
|
// Excluded: root gstack (browser tool), gstack-upgrade (gstack-specific),
|
|
// humanizer (text tool)
|
|
const SKILLS_REQUIRING_TRIGGERS = [
|
|
'qa', 'qa-only', 'ship', 'review', 'investigate', 'office-hours',
|
|
'plan-ceo-review', 'plan-eng-review', 'plan-design-review',
|
|
'design-review', 'design-consultation', 'retro', 'document-release',
|
|
'codex', 'browse', 'setup-browser-cookies', 'scrape',
|
|
];
|
|
|
|
for (const skill of SKILLS_REQUIRING_TRIGGERS) {
|
|
test(`${skill}/SKILL.md has "Use when" trigger phrases`, () => {
|
|
const skillPath = path.join(ROOT, skill, 'SKILL.md');
|
|
if (!fs.existsSync(skillPath)) return;
|
|
const content = fs.readFileSync(skillPath, 'utf-8');
|
|
// v1.45.0.0 catalog trim moved trigger prose out of frontmatter into a
|
|
// body "## When to invoke" section. Search the full file content, not
|
|
// just frontmatter. The trigger phrase must still appear somewhere in
|
|
// the skill so agents can match user requests to the skill.
|
|
expect(content).toMatch(/Use when/i);
|
|
});
|
|
}
|
|
|
|
// Skills with proactive triggers should have "Proactively suggest" somewhere in the skill.
|
|
const SKILLS_REQUIRING_PROACTIVE = [
|
|
'qa', 'qa-only', 'ship', 'review', 'investigate', 'office-hours',
|
|
'plan-ceo-review', 'plan-eng-review', 'plan-design-review',
|
|
'design-review', 'design-consultation', 'retro', 'document-release',
|
|
];
|
|
|
|
for (const skill of SKILLS_REQUIRING_PROACTIVE) {
|
|
test(`${skill}/SKILL.md has proactive routing phrase`, () => {
|
|
const skillPath = path.join(ROOT, skill, 'SKILL.md');
|
|
if (!fs.existsSync(skillPath)) return;
|
|
const content = fs.readFileSync(skillPath, 'utf-8');
|
|
// Same catalog-trim consideration — search the full file content.
|
|
expect(content).toMatch(/Proactively (suggest|invoke)/i);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── Private-path leak detector ──────────────────────────────
|
|
//
|
|
// Catches accidental references to maintainer-private files in skill output.
|
|
// Adapted from the McGluut fork's skill-contract-audit.ts (we don't take the
|
|
// whole script — these are the unique checks not already covered by
|
|
// test/gen-skill-docs.test.ts:1668-2074 .claude/skills leakage tests).
|
|
|
|
describe('Private-path leak detection', () => {
|
|
const PRIVATE_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
|
|
{ pattern: /coordination-board\.md/i, label: 'coordination-board.md' },
|
|
{ pattern: /SEEKING_LOG\.md/, label: 'SEEKING_LOG.md' },
|
|
{ pattern: /RATIONAL_SUBJECT\.md/, label: 'RATIONAL_SUBJECT.md' },
|
|
{ pattern: /VALUE_SIGNAL_LOOP\.md/, label: 'VALUE_SIGNAL_LOOP.md' },
|
|
{ pattern: /C:\\\\LLM Playground\\\\go/i, label: 'C:\\LLM Playground\\go' },
|
|
];
|
|
|
|
// Walk every SKILL.md and SKILL.md.tmpl in the repo (excluding node_modules,
|
|
// generated host outputs, and .git).
|
|
function discoverSkillSurface(): string[] {
|
|
const results: string[] = [];
|
|
function walk(dir: string) {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (entry.name.startsWith('.') && entry.name !== '.agents') continue;
|
|
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walk(full);
|
|
} else if (entry.name === 'SKILL.md' || entry.name === 'SKILL.md.tmpl') {
|
|
results.push(full);
|
|
}
|
|
}
|
|
}
|
|
walk(ROOT);
|
|
return results;
|
|
}
|
|
|
|
test('no SKILL.md or SKILL.md.tmpl references private maintainer files', () => {
|
|
const files = discoverSkillSurface();
|
|
expect(files.length).toBeGreaterThan(0);
|
|
const leaks: string[] = [];
|
|
for (const file of files) {
|
|
const content = fs.readFileSync(file, 'utf-8');
|
|
for (const { pattern, label } of PRIVATE_PATTERNS) {
|
|
if (pattern.test(content)) {
|
|
leaks.push(`${path.relative(ROOT, file)} mentions ${label}`);
|
|
}
|
|
}
|
|
}
|
|
expect(leaks).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ─── Doc-inventory cross-check ───────────────────────────────
|
|
//
|
|
// Every skill directory (with a SKILL.md.tmpl) must appear in both AGENTS.md
|
|
// and docs/skills.md. Catches the inventory drift codex flagged (/debug
|
|
// → /investigate; missing /autoplan, /context-save, /plan-devex-review, etc.).
|
|
|
|
describe('Doc inventory cross-check', () => {
|
|
// Skills that don't get user-invocation lines in agent-facing docs.
|
|
// - 'qa-only' is a sub-mode of /qa with shared docs.
|
|
// - The 5 listed below are infrastructure (model overlays, shipped binary,
|
|
// hosts) that don't show up in the user-facing skill table.
|
|
const DOC_INVENTORY_EXCLUDE = new Set([
|
|
// Infra / non-skills
|
|
'agents', 'connect-chrome', 'contrib', 'hosts',
|
|
'lib', 'model-overlays', 'openclaw', 'supabase', 'scripts', 'test',
|
|
]);
|
|
|
|
function discoverSkillDirs(): string[] {
|
|
const dirs: string[] = [];
|
|
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (entry.name.startsWith('.')) continue;
|
|
if (DOC_INVENTORY_EXCLUDE.has(entry.name)) continue;
|
|
const tmplPath = path.join(ROOT, entry.name, 'SKILL.md.tmpl');
|
|
if (fs.existsSync(tmplPath)) dirs.push(entry.name);
|
|
}
|
|
return dirs.sort();
|
|
}
|
|
|
|
test('every skill is documented in AGENTS.md', () => {
|
|
const agents = fs.readFileSync(path.join(ROOT, 'AGENTS.md'), 'utf-8');
|
|
const missing: string[] = [];
|
|
for (const skill of discoverSkillDirs()) {
|
|
// Match `/skill-name` as a token boundary.
|
|
if (!new RegExp(`/${skill}\\b`).test(agents)) missing.push(skill);
|
|
}
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
test('every skill is documented in docs/skills.md', () => {
|
|
const docs = fs.readFileSync(path.join(ROOT, 'docs', 'skills.md'), 'utf-8');
|
|
const missing: string[] = [];
|
|
for (const skill of discoverSkillDirs()) {
|
|
if (!new RegExp(`/${skill}\\b`).test(docs)) missing.push(skill);
|
|
}
|
|
expect(missing).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ─── Codex Skill Validation ──────────────────────────────────
|
|
|
|
describe('Codex skill validation', () => {
|
|
// .agents/ is gitignored (v0.11.2.0) — read from the module-level out-dir
|
|
// render (CODEX_OUT) instead of regenerating the live tree in place.
|
|
const AGENTS_DIR = path.join(CODEX_OUT, '.agents', 'skills');
|
|
|
|
// Discover all shared skills with templates.
|
|
// Host-exclusive outside-voice skills are intentionally omitted here:
|
|
// - /codex is unavailable on Codex
|
|
// - /claude-code is unavailable on Claude Code
|
|
const CLAUDE_SKILLS_WITH_TEMPLATES = (() => {
|
|
const skills: string[] = [];
|
|
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
|
|
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
if (entry.name === 'codex') continue; // Unavailable on Codex
|
|
if (entry.name === 'claude-code') continue; // Unavailable on Claude Code
|
|
if (fs.existsSync(path.join(ROOT, entry.name, 'SKILL.md.tmpl'))) {
|
|
skills.push(entry.name);
|
|
}
|
|
}
|
|
return skills;
|
|
})();
|
|
|
|
test('shared skills have both Claude and Codex variants', () => {
|
|
for (const skillDir of CLAUDE_SKILLS_WITH_TEMPLATES) {
|
|
// Claude variant
|
|
const claudeMd = path.join(ROOT, skillDir, 'SKILL.md');
|
|
expect(fs.existsSync(claudeMd)).toBe(true);
|
|
|
|
// Codex variant
|
|
const codexName = skillDir.startsWith('gstack-') ? skillDir : `gstack-${skillDir}`;
|
|
const codexMd = path.join(AGENTS_DIR, codexName, 'SKILL.md');
|
|
expect(fs.existsSync(codexMd)).toBe(true);
|
|
}
|
|
// Root template has both too
|
|
expect(fs.existsSync(path.join(ROOT, 'SKILL.md'))).toBe(true);
|
|
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack', 'SKILL.md'))).toBe(true);
|
|
});
|
|
|
|
test('/codex skill has a Claude variant and no Codex variant', () => {
|
|
// Claude variant should exist
|
|
expect(fs.existsSync(path.join(ROOT, 'codex', 'SKILL.md'))).toBe(true);
|
|
// Codex variant must NOT exist
|
|
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack-codex', 'SKILL.md'))).toBe(false);
|
|
});
|
|
|
|
test('Codex skill names follow gstack-{name} convention', () => {
|
|
const codexDirs = fs.readdirSync(AGENTS_DIR);
|
|
for (const dir of codexDirs) {
|
|
// Every directory should start with gstack
|
|
expect(dir.startsWith('gstack')).toBe(true);
|
|
// Root is just 'gstack', others are 'gstack-{name}'
|
|
if (dir !== 'gstack') {
|
|
expect(dir.startsWith('gstack-')).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('$B commands in Codex SKILL.md files are valid browse commands', () => {
|
|
const codexDirs = fs.readdirSync(AGENTS_DIR);
|
|
for (const dir of codexDirs) {
|
|
const skillMd = path.join(AGENTS_DIR, dir, 'SKILL.md');
|
|
if (!fs.existsSync(skillMd)) continue;
|
|
const content = fs.readFileSync(skillMd, 'utf-8');
|
|
// Only validate if the skill contains $B commands
|
|
if (!content.includes('$B ')) continue;
|
|
const result = validateSkill(skillMd);
|
|
expect(result.invalid).toHaveLength(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- Repo mode and test failure triage validation ---
|
|
|
|
describe('Repo mode preamble validation', () => {
|
|
test('generated SKILL.md preamble contains REPO_MODE output', () => {
|
|
// Token-reduction Phase 1: the inline `gstack-repo-mode` call moved into
|
|
// bin/gstack-skill-start. The render pins the script invocation; the
|
|
// script pins the REPO_MODE echo + the gstack-repo-mode call.
|
|
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('bin/gstack-skill-start');
|
|
const script = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), 'utf-8');
|
|
expect(script).toContain('REPO_MODE:');
|
|
expect(script).toContain('gstack-repo-mode');
|
|
});
|
|
|
|
test('tier 3+ skills contain See Something Say Something section', () => {
|
|
// Root SKILL.md is tier 1 (no Repo Mode). Check a tier 3 skill instead.
|
|
const content = fs.readFileSync(path.join(ROOT, 'plan-ceo-review', 'SKILL.md'), 'utf-8');
|
|
expect(content).toContain('See Something, Say Something');
|
|
expect(content).toContain('REPO_MODE');
|
|
expect(content).toContain('solo');
|
|
expect(content).toContain('collaborative');
|
|
});
|
|
});
|
|
|
|
describe('Test failure triage in ship skill', () => {
|
|
test('ship/SKILL.md contains Test Failure Ownership Triage', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('Test Failure Ownership Triage');
|
|
});
|
|
|
|
test('ship/SKILL.md triage uses git diff for classification', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('git diff origin/<base>...HEAD --name-only');
|
|
});
|
|
|
|
test('ship/SKILL.md triage has solo and collaborative paths', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('REPO_MODE');
|
|
expect(content).toContain('solo');
|
|
expect(content).toContain('collaborative');
|
|
expect(content).toContain('Investigate and fix now');
|
|
expect(content).toContain('Add as P0 TODO');
|
|
});
|
|
|
|
test('ship/SKILL.md triage has GitHub issue assignment for collaborative mode', () => {
|
|
const content = readShipUnion();
|
|
expect(content).toContain('gh issue create');
|
|
expect(content).toContain('--assignee');
|
|
});
|
|
|
|
test('{{TEST_FAILURE_TRIAGE}} placeholder is fully resolved in ship/SKILL.md', () => {
|
|
const content = readShipUnion();
|
|
expect(content).not.toContain('{{TEST_FAILURE_TRIAGE}}');
|
|
});
|
|
|
|
test('ship/SKILL.md uses in-branch language for stop condition', () => {
|
|
const content = readShipUnion();
|
|
expectMentions(content, [['in-branch', 'unfixed', 'stop']], 'content');
|
|
});
|
|
});
|
|
|
|
describe('no compiled binaries in git', () => {
|
|
// Tracked files enumerated once and reused by both assertions. git ls-files -z
|
|
// + split is ~ms; the previous xargs-per-file shell loops blew past 5s on CI.
|
|
const trackedFiles: string[] = require('child_process')
|
|
.execSync('git ls-files -z', { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 })
|
|
.split('\0')
|
|
.filter(Boolean);
|
|
|
|
test('git tracks no Mach-O or ELF binaries', () => {
|
|
// Only mode 100755 (executable) files can be binaries we care about. Pre-filter
|
|
// via git ls-files -s to avoid running `file` on every text file.
|
|
const lsOut: string = require('child_process').execSync('git ls-files -s', {
|
|
cwd: ROOT,
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
});
|
|
const executableFiles = lsOut
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((line: string) => {
|
|
const parts = line.split(/\s+/);
|
|
return { mode: parts[0], file: line.split('\t')[1] };
|
|
})
|
|
.filter((e: { mode: string; file: string }) => e.mode === '100755')
|
|
.map((e: { mode: string; file: string }) => e.file);
|
|
|
|
if (executableFiles.length === 0) return;
|
|
|
|
// Batch-invoke `file --mime-type` across all executable files at once.
|
|
const result: string = require('child_process')
|
|
.execSync(`file --mime-type -- ${executableFiles.map((f: string) => `'${f.replace(/'/g, "'\\''")}'`).join(' ')}`, {
|
|
cwd: ROOT,
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
})
|
|
.trim();
|
|
|
|
const binaries = result
|
|
.split('\n')
|
|
.filter((l: string) =>
|
|
/application\/(x-mach-binary|x-executable|x-pie-executable|x-sharedlib)/.test(l)
|
|
)
|
|
.map((l: string) => l.split(':')[0].trim());
|
|
|
|
expect(binaries).toEqual([]);
|
|
});
|
|
|
|
test('warns about tracked files larger than 2MB', () => {
|
|
// Large fixtures can be legitimate test infrastructure. Keep visibility on
|
|
// repository size without blocking those fixtures from living in git.
|
|
// Known-good fixtures are exempted from the warning to keep CI logs clean.
|
|
const MAX_BYTES = 2 * 1024 * 1024;
|
|
const knownLargeFixtures = new Set<string>([
|
|
// Currently empty — add repo-relative paths of intentionally-committed
|
|
// large fixtures here with a reason.
|
|
]);
|
|
const oversized = trackedFiles.flatMap((f: string) => {
|
|
if (knownLargeFixtures.has(f)) return [];
|
|
const full = path.join(ROOT, f);
|
|
try {
|
|
const size = fs.statSync(full).size;
|
|
return size > MAX_BYTES ? [{ file: f, size }] : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
|
|
if (oversized.length > 0) {
|
|
const formatted = oversized
|
|
.map(({ file, size }: { file: string; size: number }) => {
|
|
const mib = (size / (1024 * 1024)).toFixed(1);
|
|
return `${file} (${mib} MiB)`;
|
|
})
|
|
.join(', ');
|
|
console.warn(`[size-warning] tracked files over 2 MiB: ${formatted}`);
|
|
}
|
|
|
|
expect(Array.isArray(oversized)).toBe(true);
|
|
});
|
|
});
|
|
|
|
|
|
// ─── Browser-skills validation ──────────────────────────────────
|
|
//
|
|
// Browser-skills are bundled in <gstack-root>/browser-skills/<name>/. Each
|
|
// must have a SKILL.md whose frontmatter satisfies the contract enforced by
|
|
// browse/src/browser-skills.ts:parseSkillFile (host required, args + triggers
|
|
// parseable as the right shape). This test catches malformed bundled skills
|
|
// at CI time, before they ship.
|
|
|
|
describe('Bundled browser-skills frontmatter contract', () => {
|
|
const browserSkillsRoot = path.join(ROOT, 'browser-skills');
|
|
|
|
function listBundledSkillDirs(): string[] {
|
|
if (!fs.existsSync(browserSkillsRoot)) return [];
|
|
return fs.readdirSync(browserSkillsRoot)
|
|
.filter(name => !name.startsWith('.'))
|
|
.map(name => path.join(browserSkillsRoot, name))
|
|
.filter(dir => {
|
|
try { return fs.statSync(dir).isDirectory(); } catch { return false; }
|
|
});
|
|
}
|
|
|
|
test('each bundled skill has a SKILL.md', () => {
|
|
for (const dir of listBundledSkillDirs()) {
|
|
const skillFile = path.join(dir, 'SKILL.md');
|
|
expect(fs.existsSync(skillFile)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('each bundled skill SKILL.md frontmatter parses with required fields', async () => {
|
|
const { parseSkillFile } = await import('../browse/src/browser-skills');
|
|
for (const dir of listBundledSkillDirs()) {
|
|
const name = path.basename(dir);
|
|
const content = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf-8');
|
|
// parseSkillFile throws on missing required fields; we just want to
|
|
// make sure none of our shipped skills tripwire it.
|
|
const { frontmatter } = parseSkillFile(content, { skillName: name });
|
|
expect(frontmatter.name).toBe(name);
|
|
expect(typeof frontmatter.host).toBe('string');
|
|
expect(frontmatter.host.length).toBeGreaterThan(0);
|
|
expect(Array.isArray(frontmatter.triggers)).toBe(true);
|
|
expect(Array.isArray(frontmatter.args)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('each bundled skill has a script.ts', () => {
|
|
for (const dir of listBundledSkillDirs()) {
|
|
expect(fs.existsSync(path.join(dir, 'script.ts'))).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('each bundled skill ships a sibling SDK at _lib/browse-client.ts', () => {
|
|
for (const dir of listBundledSkillDirs()) {
|
|
expect(fs.existsSync(path.join(dir, '_lib', 'browse-client.ts'))).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('each bundled skill has a script.test.ts', () => {
|
|
for (const dir of listBundledSkillDirs()) {
|
|
expect(fs.existsSync(path.join(dir, 'script.test.ts'))).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("each bundled skill's _lib/browse-client.ts matches the canonical SDK", () => {
|
|
// If the canonical SDK changes, the bundled copy must be updated. This
|
|
// test enforces that — the _lib copy should be byte-identical.
|
|
const canonical = fs.readFileSync(path.join(ROOT, 'browse', 'src', 'browse-client.ts'), 'utf-8');
|
|
for (const dir of listBundledSkillDirs()) {
|
|
const sibling = fs.readFileSync(path.join(dir, '_lib', 'browse-client.ts'), 'utf-8');
|
|
expect(sibling).toBe(canonical);
|
|
}
|
|
});
|
|
|
|
test('script.ts imports browse from ./_lib/browse-client', () => {
|
|
for (const dir of listBundledSkillDirs()) {
|
|
const content = fs.readFileSync(path.join(dir, 'script.ts'), 'utf-8');
|
|
expect(content).toMatch(/from\s+['"]\.\/_lib\/browse-client['"]/);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Token-reduction Phase 5: the four ios skills demoted from preamble-tier 3
|
|
// to 2 — they never consume the tier-3 sections (repo-mode ownership, search
|
|
// before building) but DO fire AskUserQuestion, which tier >=2 provides.
|
|
describe('ios tier demotion (Phase 5)', () => {
|
|
const iosSkills = ['ios-fix', 'ios-clean', 'ios-sync', 'ios-design-review'];
|
|
for (const skill of iosSkills) {
|
|
test(`${skill} render drops tier-3 sections, keeps AUQ format`, () => {
|
|
const content = fs.readFileSync(path.join(ROOT, skill, 'SKILL.md'), 'utf-8');
|
|
expect(content).not.toContain('## Repo Ownership');
|
|
expect(content).not.toContain('## Search Before Building');
|
|
expect(content).toContain('## AskUserQuestion Format');
|
|
});
|
|
}
|
|
});
|