Merge quality gate baseline work

Bring the detached quality-gate worktree into local main while preserving the existing main-only desktop diagnostics commits. This merge lands the PR/release quality gate, live model baselines, provider selector docs, AGENTS automation rules, and repository hygiene for generated artifacts.

Constraint: Local main had commits not present in the detached worktree, so fast-forward merge was not possible.

Rejected: Rebase the detached worktree | rewriting the already-verified local commits would discard the exact release-gate evidence SHA chain.

Confidence: high

Scope-risk: broad

Directive: Keep quality reports and local Superpowers plans out of future commits; use report summaries as evidence, not tracked artifacts.

Tested: bun run quality:gate --mode release --allow-live --provider-model codingplan:main:codingplan-main --provider-model minimax:main:minimax-main

Not-tested: post-merge full rerun on main; merge had no content conflicts.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-02 16:22:30 +08:00
commit e7f6f8dfe5
53 changed files with 2679 additions and 1387 deletions

3
.gitignore vendored
View File

@ -22,6 +22,9 @@ extracted-natives/
# E2E screenshots
e2e-*.png
# Quality gate reports
artifacts/quality-runs/
# Desktop build output
desktop/dist/
desktop/build-artifacts/

View File

@ -13,6 +13,10 @@ Install root dependencies with `bun install`, then install desktop dependencies
- `cd desktop && bun run build`: type-check and produce a production web build.
- `cd desktop && bun run test`: run Vitest suites.
- `cd desktop && bun run lint`: run TypeScript no-emit checks.
- `bun run quality:providers`: list configured provider/model selectors for live agent baselines.
- `bun run quality:pr`: run the local PR quality gate and write a report under `artifacts/quality-runs/`.
- `bun run quality:gate --mode baseline --allow-live --provider-model <provider:model[:label]>`: run live Coding Agent baseline cases, including desktop agent-browser smoke.
- `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>`: run the release gate with live baseline coverage.
## Desktop Release Workflow
- Desktop releases are built remotely by GitHub Actions, not by uploading local build artifacts.
@ -32,6 +36,23 @@ Use TypeScript with 2-space indentation, ESM imports, and no semicolons to match
## Testing Guidelines
Desktop tests use Vitest with Testing Library in a `jsdom` environment. Name tests `*.test.ts` or `*.test.tsx`; colocate focused tests near the file or place broader coverage in `desktop/src/__tests__/`. No coverage gate is configured, so add regression tests for any behavior you change and run the relevant suites before opening a PR.
## Quality Gate Automation
Future Coding Agents should run the right local gate themselves before claiming a change is ready. Do not ask the user to manually run the commands unless credentials, local model access, or machine resources are missing.
- For normal code changes, run the narrow relevant check first, then `bun run quality:pr` before a PR-ready or merge-ready claim.
- Use `bun run check:server` for `src/server`, `src/tools`, provider/runtime, MCP, OAuth, WebSocket, or API behavior changes.
- Use `bun run check:desktop` for `desktop/src` UI, stores, API clients, and desktop web behavior changes.
- Use `bun run check:native` for `desktop/src-tauri`, sidecars, native packaging, release, or platform startup behavior changes.
- Use `bun run check:adapters` for `adapters/`; on a fresh checkout run `cd adapters && bun install` first if dependencies are missing.
- Use `bun run check:docs` for docs, VitePress, README, or docs workflow changes.
- For chat, agent loop, tool execution, provider routing, desktop chat UI, CLI task execution, or other core Coding Agent paths, also run a live baseline when local providers are available: first `bun run quality:providers`, then choose one or more copyable selectors and run `bun run quality:gate --mode baseline --allow-live --provider-model <provider:model[:label]>`.
- For release readiness, run `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>` with at least one real provider/model selector. Prefer multiple providers when quota is available.
- If no live provider is configured, or a provider quota/key is unavailable, run the non-live gate anyway and report the live-baseline blocker explicitly instead of claiming full release confidence.
- `bun run check:docs` executes `npm ci`, which can rebuild root `node_modules`. Run docs checks sequentially, not in parallel with `quality:pr`, `check:native`, or other commands that depend on the same installed packages.
- Quality reports are written to `artifacts/quality-runs/<timestamp>/`. Summarize the final report path and the pass/fail counts in handoffs and PR descriptions.
- Do not commit generated `artifacts/quality-runs/`, local `.omx/` state, `node_modules/`, `desktop/node_modules/`, or adapter dependency folders.
- Do not claim "complete", "ready to merge", or "ready to release" without either running the matching gate or naming the exact blocker that prevented it.
## Commit & Pull Request Guidelines
Recent history follows Conventional Commit prefixes such as `feat:`, `fix:`, and `docs:`. Keep subjects imperative and scoped to one change. PRs should explain the user-visible impact, list verification steps, link related issues, and include screenshots for desktop or docs UI changes. Keep diffs reviewable and call out any follow-up work or known gaps.
Branch names should use normal product prefixes such as `fix/xxx`, `feat/xxx`, or `docs/xxx`; do not create `codex/`-prefixed branches in this repository.

31
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,31 @@
# Contributing
Thanks for helping improve Claude Code Haha.
For the full contributor guide, including local checks, live model baselines, quality-gate reports, and PR expectations, see:
- Chinese: [docs/guide/contributing.md](docs/guide/contributing.md)
- English: [docs/en/guide/contributing.md](docs/en/guide/contributing.md)
Most contributors should run this before opening a PR:
```bash
bun install
bun run quality:pr
```
If you run adapter or native checks on a fresh clone, install adapter dependencies too:
```bash
cd adapters
bun install
```
If your change touches the desktop chat path, provider/runtime selection, CLI bridge, permissions, tools, file editing, or release packaging, also run a live baseline with your own local model provider:
```bash
bun run quality:providers
bun run quality:gate --mode baseline --allow-live --provider-model <selector>:main
```
Quality reports are written under `artifacts/quality-runs/<timestamp>/`.

View File

@ -195,6 +195,7 @@ http://127.0.0.1:2024
|------|------|
| [Environment Variables](docs/en/guide/env-vars.md) | Full env var reference and configuration methods |
| [Third-Party Models](docs/en/guide/third-party-models.md) | Using OpenAI / DeepSeek / Ollama and other non-Anthropic models |
| [Contributing](docs/en/guide/contributing.md) | Local tests, live model baselines, PR gates, and release gates |
| [Memory System](docs/memory/01-usage-guide.md) | Cross-session persistent memory usage and implementation |
| [Multi-Agent System](docs/agent/01-usage-guide.md) | Agent orchestration, parallel tasks and Teams collaboration |
| [Skills System](docs/skills/01-usage-guide.md) | Extensible capability plugins, custom workflows and conditional activation |

View File

@ -195,6 +195,7 @@ http://127.0.0.1:2024
|------|------|
| [环境变量](docs/guide/env-vars.md) | 完整环境变量参考和配置方式 |
| [第三方模型](docs/guide/third-party-models.md) | 接入 OpenAI / DeepSeek / Ollama 等非 Anthropic 模型 |
| [贡献与质量门禁](docs/guide/contributing.md) | 本地测试、真实模型 baseline、PR 和 release 门禁 |
| [记忆系统](docs/memory/01-usage-guide.md) | 跨会话持久化记忆的使用与实现 |
| [多 Agent 系统](docs/agent/01-usage-guide.md) | 多代理编排、并行任务执行与 Teams 协作 |
| [Skills 系统](docs/skills/01-usage-guide.md) | 可扩展能力插件、自定义工作流与条件激活 |

View File

@ -24,6 +24,7 @@ const zhSidebar = [
{ text: '第三方模型', link: '/guide/third-party-models' },
{ text: '全局使用', link: '/guide/global-usage' },
{ text: '常见问题', link: '/guide/faq' },
{ text: '贡献与质量门禁', link: '/guide/contributing' },
],
},
{
@ -109,6 +110,7 @@ const enSidebar = [
{ text: 'Third-Party Models', link: '/en/guide/third-party-models' },
{ text: 'Global Usage', link: '/en/guide/global-usage' },
{ text: 'FAQ', link: '/en/guide/faq' },
{ text: 'Contributing', link: '/en/guide/contributing' },
],
},
{

View File

@ -0,0 +1,164 @@
# Contributing and Local Quality Gates
This guide explains how to install, develop, test, and run the local quality gates before opening a PR. The goal is to help maintainers and contributors answer one question before review: did this change break the core Coding Agent workflow?
## Setup
Install root dependencies with Bun:
```bash
bun install
```
If your change touches `desktop/`, also install desktop dependencies:
```bash
cd desktop
bun install
```
If your change touches `adapters/`, or if you run `check:adapters` / `check:native`, install adapter dependencies:
```bash
cd adapters
bun install
```
Do not commit local artifacts such as `artifacts/quality-runs/`, `node_modules/`, or `desktop/node_modules/`.
## Required PR Gate
Before opening a normal PR, run:
```bash
bun run quality:pr
```
This gate does not call real models, so every contributor can run it locally. It writes reports to:
```text
artifacts/quality-runs/<timestamp>/report.md
artifacts/quality-runs/<timestamp>/report.json
```
Include the commands you ran and the report summary in your PR description.
## Area-Specific Checks
Run the checks that match the files you changed:
```bash
bun run check:server # Server API, WebSocket, providers, sessions, and related tests
bun run check:desktop # Desktop lint, Vitest, and production build
bun run check:adapters # IM adapter tests
bun run check:native # Desktop sidecars and Tauri native checks
bun run check:docs # Docs build, using npm ci + docs:build
```
Focused tests are fine while developing, but run `bun run quality:pr` before sending the PR.
## Live Model Baseline
`quality:baseline` runs real Coding Agent tasks: it starts the local server, creates isolated fixtures, asks a model through chat to fix code, runs tests, and saves transcripts, diffs, verification logs, and a report.
The default baseline command does not call real models:
```bash
bun run quality:baseline
```
To actually call models, pass `--allow-live` and choose a local provider.
First list your local providers and copyable selectors:
```bash
bun run quality:providers
```
Example output:
```text
Saved providers:
MiniMax
selector: minimax
main: MiniMax-M2.7-highspeed
--provider-model minimax:main:minimax-main
```
Copy one of the listed values:
```bash
bun run quality:gate --mode baseline --allow-live --provider-model minimax:main:minimax-main
```
You can run multiple models in one pass:
```bash
bun run quality:gate --mode baseline --allow-live \
--provider-model codingplan:main:codingplan-main \
--provider-model minimax:main:minimax-main
```
Provider selectors come from the providers saved in your local Desktop Settings > Providers page. Contributors do not need the maintainer's provider UUIDs or vendor accounts. They can add their own provider locally, run `bun run quality:providers`, and choose their own model.
## When To Run The Baseline
Run the live baseline for changes touching:
- Desktop chat, session resume, WebSocket, or the CLI bridge
- Provider, model, or runtime selection
- Permissions, tool calls, file edits, and task execution
- agent-browser smoke, Computer Use, Skills, or MCP
- Release preparation or broad cross-module refactors
If you do not have model access, still run `bun run quality:pr` and state in the PR why the live baseline was not run.
## Release Gate
Before a release, run release mode:
```bash
bun run quality:gate --mode release --allow-live --provider-model <selector>:main
```
Release mode composes PR checks, baseline catalog validation, live baseline cases, desktop smoke, and native checks. Reports are written to `artifacts/quality-runs/<timestamp>/`.
## PR Workflow
1. Create a product branch such as `fix/session-reconnect` or `feat/provider-quality-gate`.
2. Install dependencies and make the change.
3. Add tests for behavior changes.
4. Run focused checks for the affected area.
5. Run `bun run quality:pr`.
6. Run the live baseline for high-risk changes.
7. In the PR description, include user impact, verification commands, report summary, and known risks.
## FAQ
### Can I run checks without a provider?
Yes. Run the normal PR gate:
```bash
bun run quality:pr
```
Only the live baseline needs a real model. Add your provider in Desktop Settings > Providers, then run:
```bash
bun run quality:providers
```
### What if provider selectors conflict?
If two provider names produce the same selector, `quality:providers` falls back to the provider ID. Copy the `--provider-model ...` value it prints.
### What if a model ID contains a colon?
Prefer role selectors:
```bash
--provider-model custom:haiku:custom-haiku
```
The runner resolves `haiku` to the real model ID from your local provider configuration.

164
docs/guide/contributing.md Normal file
View File

@ -0,0 +1,164 @@
# 贡献指南与本地质量门禁
这份文档说明贡献代码前应该如何在本地安装、开发、测试和运行质量门禁。目标是让维护者和贡献者都能在提交 PR 前回答一个问题:这次改动有没有破坏核心 Coding Agent 工作流。
## 环境准备
项目根目录使用 Bun
```bash
bun install
```
如果改动涉及 `desktop/`,也安装桌面端依赖:
```bash
cd desktop
bun install
```
如果改动涉及 `adapters/`,或者要运行 `check:adapters` / `check:native`,安装 adapter 依赖:
```bash
cd adapters
bun install
```
不要提交本地运行产物,例如 `artifacts/quality-runs/``node_modules/``desktop/node_modules/`
## 普通 PR 必跑门禁
普通贡献者在提交 PR 前至少运行:
```bash
bun run quality:pr
```
这个门禁不调用真实大模型,适合所有人本地运行。它会生成报告:
```text
artifacts/quality-runs/<timestamp>/report.md
artifacts/quality-runs/<timestamp>/report.json
```
PR 描述里请贴出你实际运行的命令和 summary。
## 按改动范围补充测试
根据你改动的区域补充运行:
```bash
bun run check:server # 服务端 API、WebSocket、provider、会话等测试
bun run check:desktop # 桌面端 lint、Vitest、生产构建
bun run check:adapters # IM adapter 测试
bun run check:native # 桌面 sidecar 与 Tauri native 检查
bun run check:docs # 文档构建,使用 npm ci + docs:build
```
如果只改了很窄的文件,也可以先跑对应的定向测试,但 PR 前仍应跑 `bun run quality:pr`
## 真实模型 Baseline
`quality:baseline` 用来跑真实 Coding Agent 任务:启动本地服务端、创建隔离 fixture、让模型通过聊天修代码、跑测试并保存 transcript、diff、verification log 和报告。
默认命令不会调用真实模型:
```bash
bun run quality:baseline
```
要真正跑模型,必须显式加 `--allow-live` 并选择本机 provider。
先列出本机可用 provider 和可复制参数:
```bash
bun run quality:providers
```
输出示例:
```text
Saved providers:
MiniMax
selector: minimax
main: MiniMax-M2.7-highspeed
--provider-model minimax:main:minimax-main
```
复制输出里的参数运行 baseline
```bash
bun run quality:gate --mode baseline --allow-live --provider-model minimax:main:minimax-main
```
可以一次跑多个模型:
```bash
bun run quality:gate --mode baseline --allow-live \
--provider-model codingplan:main:codingplan-main \
--provider-model minimax:main:minimax-main
```
`provider` selector 来自桌面端「Settings > Providers」里保存的本机配置。别人 clone 代码后不需要知道你的 provider UUID也不需要使用你的供应商他们可以在自己的桌面端添加 provider 后运行 `bun run quality:providers` 选择自己的模型。
## 什么时候必须跑 Baseline
以下改动建议跑 live baseline
- 桌面聊天、会话恢复、WebSocket、CLI bridge
- provider/model/runtime 选择
- 权限、工具调用、文件编辑、任务执行
- agent-browser smoke、Computer Use、Skills、MCP
- release 前或风险较大的跨模块重构
如果没有模型额度,至少运行 `bun run quality:pr`,并在 PR 里说明未跑 live baseline 的原因。
## Release 门禁
发版前使用 release 模式:
```bash
bun run quality:gate --mode release --allow-live --provider-model <selector>:main
```
release 模式会组合 PR checks、baseline catalog、live baseline、desktop smoke 和 native checks。发版报告同样写入 `artifacts/quality-runs/<timestamp>/`
## PR 提交流程
1. 新建普通产品分支,例如 `fix/session-reconnect``feat/provider-quality-gate`
2. 安装依赖并完成改动。
3. 为行为变化补测试。
4. 运行相关定向测试。
5. 运行 `bun run quality:pr`
6. 对高风险改动运行 live baseline。
7. 在 PR 描述里写清楚用户影响、测试命令、报告 summary、已知风险。
## 常见问题
### 没有 provider 可以跑吗?
可以跑普通门禁:
```bash
bun run quality:pr
```
只有 live baseline 需要真实模型。先在桌面端 Settings > Providers 添加自己的 provider再运行
```bash
bun run quality:providers
```
### provider selector 冲突怎么办?
如果两个 provider 名称生成了相同 selector`quality:providers` 会退回输出 provider ID。直接复制它给出的 `--provider-model ...` 即可。
### 模型 ID 里带冒号怎么办?
优先使用角色选择,例如:
```bash
--provider-model custom:haiku:custom-haiku
```
脚本会把 `haiku` 解析成本机 provider 配置里的真实模型 ID。

File diff suppressed because it is too large Load Diff

View File

@ -1,271 +0,0 @@
# Skills UI Feature Design
## Overview
Port the CLI `/skills` listing to the Desktop Web APP, adding a full Skills browser with file tree navigation and content preview in the Settings page.
## Architecture
```
Desktop UI (React) Server (Bun.serve) Filesystem
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Settings Page │ │ GET /api/skills │ │ ~/.claude/skills/│
│ └─ Skills Tab │──────▶│ → list meta │──────▶│ ./.claude/skills/│
│ ├─ SkillList │ │ │ │ plugins cache/ │
│ └─ SkillDetail │ GET /api/skills/ │ │ bundled skills/ │
│ ├─ FileTree│──────▶│ detail?s=&n= │──────▶│ │
│ └─ Viewer │ │ → tree + files │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
```
## Data Models
### SkillMeta (list item)
```typescript
type SkillSource = 'userSettings' | 'projectSettings' | 'policySettings'
| 'plugin' | 'mcp' | 'bundled'
type SkillMeta = {
name: string // unique name (plugin format: "plugin:skill")
displayName?: string // custom display name from frontmatter
description: string
source: SkillSource
userInvocable: boolean
version?: string
contentLength: number // char count for token estimation
pluginName?: string // plugin name when source is 'plugin'
hasDirectory: boolean // whether the skill has a browsable directory
}
```
### FileTreeNode
```typescript
type FileTreeNode = {
name: string // file/dir name
path: string // relative to skill root
type: 'file' | 'directory'
children?: FileTreeNode[] // present for directories
}
```
### SkillFile
```typescript
type SkillFile = {
path: string // relative path
content: string // file content
language: string // language id (md, ts, json, yaml, sh, etc.)
}
```
### SkillDetail (detail view)
```typescript
type SkillDetail = {
meta: SkillMeta
tree: FileTreeNode[] // directory tree
files: SkillFile[] // all file contents, loaded at once
skillRoot: string // absolute path (display only)
}
```
## Server API
### `GET /api/skills`
Returns metadata list of all installed skills.
**Response:**
```json
{
"skills": [
{
"name": "my-skill",
"displayName": "My Skill",
"description": "Does something useful",
"source": "userSettings",
"userInvocable": true,
"contentLength": 1234,
"hasDirectory": true
}
]
}
```
**Implementation:** Reuse `loadAllCommands()` from `src/commands.ts`, filter for `type === 'prompt'` commands with skill-related `loadedFrom` values. Extract metadata without loading full content.
### `GET /api/skills/detail?source={source}&name={name}`
Returns full skill data including file tree and all file contents.
**Response:**
```json
{
"detail": {
"meta": { ... },
"tree": [
{ "name": "SKILL.md", "path": "SKILL.md", "type": "file" },
{
"name": "examples",
"path": "examples",
"type": "directory",
"children": [
{ "name": "demo.ts", "path": "examples/demo.ts", "type": "file" }
]
}
],
"files": [
{ "path": "SKILL.md", "content": "---\nname: ...\n---\n# ...", "language": "md" },
{ "path": "examples/demo.ts", "content": "...", "language": "ts" }
],
"skillRoot": "/Users/x/.claude/skills/my-skill"
}
}
```
**Implementation:**
1. Find matching skill by source + name from loaded commands
2. Get `skillRoot` from the command object
3. Recursively walk the directory, skip `node_modules`, `.git`, hidden dirs
4. Read all files (max 100KB per file, max 50 files total as safety limit)
5. Detect language from file extension
6. For MCP skills (no directory): return empty tree, single virtual file with the skill's prompt content
### File: `src/server/api/skills.ts`
New file following the pattern of `src/server/api/status.ts`.
### Router registration: `src/server/router.ts`
Add `case 'skills': return handleSkillsApi(req, url, segments)`.
## Desktop UI
### Settings Tab Addition
Add a 5th tab "Skills" (icon: `auto_awesome`) to `desktop/src/pages/Settings.tsx`.
```
type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'skills'
```
### Skill List View (`SkillList.tsx`)
- Grouped by source (User, Project, Plugin, MCP, Bundled)
- Each group: collapsible header with count
- Each item: name, description (truncated), source badge
- Click → navigate to detail view
- Loading state + empty state
### Skill Detail View (`SkillDetail.tsx`)
Two-panel layout:
- **Left panel (w-[220px]):** File tree with expand/collapse
- **Right panel (flex-1):** File content viewer
- **Header:** Back button, skill name, description, source badge, token estimate
- Default selected file: SKILL.md
### File Tree Component (`FileTree.tsx`)
- Recursive tree with indent
- Directory expand/collapse (default: all expanded)
- File icons based on extension
- Click file → show in right panel
- Active file highlight
### File Viewer (`FileViewer.tsx`)
- For `.md` files: Use existing `MarkdownRenderer`
- For code files: Use existing `CodeViewer` with language detection
- File path header with copy button
- Scrollable content area
### Language Detection
Map file extension to language:
```typescript
const LANG_MAP: Record<string, string> = {
md: 'markdown', ts: 'typescript', tsx: 'typescript',
js: 'javascript', jsx: 'javascript', json: 'json',
yaml: 'yaml', yml: 'yaml', sh: 'bash', bash: 'bash',
py: 'python', toml: 'toml', css: 'css', html: 'html',
}
```
## State Management
### `desktop/src/stores/skillStore.ts`
```typescript
type SkillStoreState = {
skills: SkillMeta[]
selectedSkill: SkillDetail | null
isLoading: boolean
isDetailLoading: boolean
error: string | null
fetchSkills: () => Promise<void>
fetchSkillDetail: (source: string, name: string) => Promise<void>
clearSelection: () => void
}
```
### `desktop/src/api/skills.ts`
```typescript
export const skillsApi = {
list: () => api.get<{ skills: SkillMeta[] }>('/api/skills'),
detail: (source: string, name: string) =>
api.get<{ detail: SkillDetail }>(`/api/skills/detail?source=${encodeURIComponent(source)}&name=${encodeURIComponent(name)}`),
}
```
### `desktop/src/types/skill.ts`
All type definitions from Data Models section.
## i18n
Add to both `en.ts` and `zh.ts`:
```typescript
'settings.tab.skills': 'Skills' / '技能',
'settings.skills.title': 'Installed Skills' / '已安装技能',
'settings.skills.description': 'Skills extend Claude...' / '技能扩展 Claude...',
'settings.skills.empty': 'No skills installed' / '暂无已安装技能',
'settings.skills.back': 'Back to list' / '返回列表',
'settings.skills.tokens': '~{count} tokens',
'settings.skills.files': '{count} files' / '{count} 个文件',
'settings.skills.source.userSettings': 'User' / '用户',
'settings.skills.source.projectSettings': 'Project' / '项目',
'settings.skills.source.plugin': 'Plugin' / '插件',
'settings.skills.source.mcp': 'MCP',
'settings.skills.source.bundled': 'Built-in' / '内置',
```
## Files to Create/Modify
### New files (7):
1. `src/server/api/skills.ts` — Server API handler
2. `desktop/src/types/skill.ts` — Type definitions
3. `desktop/src/api/skills.ts` — API client
4. `desktop/src/stores/skillStore.ts` — Zustand store
5. `desktop/src/components/skills/SkillList.tsx` — Skill list component
6. `desktop/src/components/skills/SkillDetail.tsx` — Detail view (FileTree + FileViewer inline)
### Modified files (4):
7. `src/server/router.ts` — Add skills route
8. `desktop/src/pages/Settings.tsx` — Add Skills tab
9. `desktop/src/i18n/locales/en.ts` — English translations
10. `desktop/src/i18n/locales/zh.ts` — Chinese translations
## Design Decisions
1. **One detail request loads all files** — Single skill directories are small (typically <50KB total), so loading all at once avoids per-file request overhead.
2. **Reuse existing components**`MarkdownRenderer` for .md files, `CodeViewer` for code. No new rendering dependencies.
3. **source + name as identifier** — Skills can have duplicate names across sources, so both are needed.
4. **Server-side skill loading** — Reuse `loadAllCommands()` which already handles all 6 sources with deduplication and caching.
5. **Safety limits** — Max 50 files, 100KB per file to prevent issues with massive plugin directories.
6. **FileTree + FileViewer inline in SkillDetail** — No need for separate components given the simplicity; keeps code collocated.

View File

@ -17,6 +17,11 @@
"check:adapters": "cd adapters && bun test",
"check:native": "cd desktop && bun run build:sidecars && cd src-tauri && cargo check",
"check:docs": "npm ci && npm run docs:build",
"quality:gate": "bun run scripts/quality-gate/index.ts",
"quality:providers": "bun run scripts/quality-gate/providers.ts",
"quality:pr": "bun run quality:gate --mode pr",
"quality:baseline": "bun run quality:gate --mode baseline",
"quality:release": "bun run quality:gate --mode release",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"

View File

@ -2,18 +2,11 @@
import { readdirSync, statSync } from 'node:fs'
import { join, relative, sep } from 'node:path'
import { quarantinedPathSet } from '../quality-gate/quarantine'
const root = process.cwd()
const roots = ['src/server', 'src/tools', 'src/utils']
const excludedFiles = new Set([
// These suites are not stable enough for the default PR gate yet. Keep them
// out of CI until they are fixed or moved to a maintainer-only workflow.
'src/server/__tests__/cron-scheduler.test.ts',
'src/server/__tests__/providers-real.test.ts',
'src/server/__tests__/tasks.test.ts',
'src/server/__tests__/e2e/business-flow.test.ts',
'src/server/__tests__/e2e/full-flow.test.ts',
])
const excludedFiles = quarantinedPathSet()
function normalize(path: string) {
return relative(root, path).split(sep).join('/')

View File

@ -0,0 +1,49 @@
import { describe, expect, test } from 'bun:test'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { baselineCases, validateBaselineCases } from './cases'
describe('baselineCases', () => {
test('have valid metadata', () => {
expect(() => validateBaselineCases()).not.toThrow()
})
test('use unique ids', () => {
const ids = baselineCases.map((testCase) => testCase.id)
expect(new Set(ids).size).toBe(ids.length)
})
test('point at existing fixtures with package manifests', () => {
for (const testCase of baselineCases) {
expect(existsSync(testCase.fixture)).toBe(true)
expect(existsSync(join(testCase.fixture, 'package.json'))).toBe(true)
}
})
test('require real model capability', () => {
for (const testCase of baselineCases) {
expect(testCase.requiredCapabilities).toContain('model')
}
})
test('define enough first-wave product baseline cases', () => {
expect(baselineCases.map((testCase) => testCase.id)).toEqual([
'failing-unit',
'multi-file-api',
'failure-recovery',
'workspace-search-edit',
'permission-artifact',
'cross-module-refactor',
])
})
test('pin changed-file expectations for every case', () => {
for (const testCase of baselineCases) {
expect(testCase.verify.requiredFiles?.length ?? 0).toBeGreaterThan(0)
expect(testCase.verify.expectedFiles?.length ?? 0).toBeGreaterThan(0)
for (const file of testCase.verify.requiredFiles ?? []) {
expect(testCase.verify.expectedFiles).toContain(file)
}
}
})
})

View File

@ -0,0 +1,127 @@
import type { BaselineCase } from '../types'
export const baselineCases: BaselineCase[] = [
{
id: 'failing-unit',
title: 'Fix a failing unit test',
description: 'A tiny TypeScript project has a broken arithmetic function. The Agent must inspect the failing test, patch the implementation, and rerun the test.',
fixture: 'scripts/quality-gate/baseline/fixtures/failing-unit',
prompt: 'Run the tests, inspect the failing assertion, fix the implementation bug, and rerun the tests until they pass. Only modify the fixture source files needed for the fix.',
mode: 'websocket',
requiredCapabilities: ['model', 'file-edit', 'shell'],
timeoutMs: 180_000,
verify: {
commands: [['bun', 'test']],
requiredFiles: ['src/math.ts'],
expectedFiles: ['src/math.ts'],
forbiddenFiles: ['package.json'],
transcriptAssertions: ['"type":"message_complete"'],
},
},
{
id: 'multi-file-api',
title: 'Update a multi-file API contract',
description: 'A small app exposes a user display API. The Agent must change the contract and update callers coherently without loosening tests.',
fixture: 'scripts/quality-gate/baseline/fixtures/multi-file-api',
prompt: 'The user display API contract changed to return an object with a label field, where label is "Ada Lovelace <ada@example.com>". Update the implementation and caller so the existing tests pass. Do not edit package.json.',
mode: 'websocket',
requiredCapabilities: ['model', 'file-edit', 'shell'],
timeoutMs: 240_000,
verify: {
commands: [['bun', 'test']],
requiredFiles: ['src/api.ts', 'src/app.ts'],
expectedFiles: ['src/api.ts', 'src/app.ts', 'src/app.test.ts'],
forbiddenFiles: ['package.json'],
transcriptAssertions: ['"type":"message_complete"'],
},
},
{
id: 'failure-recovery',
title: 'Recover from a deeper failing test suite',
description: 'A slug formatter has several edge-case failures. The Agent must read the failures, patch the implementation, and keep tests unchanged.',
fixture: 'scripts/quality-gate/baseline/fixtures/failure-recovery',
prompt: 'Run the tests and fix the slug formatter implementation until the whole suite passes. Do not edit the tests. Keep the implementation small and deterministic.',
mode: 'websocket',
requiredCapabilities: ['model', 'file-edit', 'shell'],
timeoutMs: 240_000,
verify: {
commands: [['bun', 'test']],
requiredFiles: ['src/slug.ts'],
expectedFiles: ['src/slug.ts'],
forbiddenFiles: ['src/slug.test.ts', 'package.json'],
transcriptAssertions: ['"type":"message_complete"'],
},
},
{
id: 'workspace-search-edit',
title: 'Find and fix a bug across a small workspace',
description: 'A checkout total is wrong in a multi-file fixture. The Agent must inspect the workspace and patch the correct implementation file.',
fixture: 'scripts/quality-gate/baseline/fixtures/workspace-search-edit',
prompt: 'The checkout tests are failing. Search the project, identify the real bug, fix the implementation, and rerun the tests. Do not rewrite unrelated modules.',
mode: 'websocket',
requiredCapabilities: ['model', 'file-edit', 'shell'],
timeoutMs: 240_000,
verify: {
commands: [['bun', 'test']],
requiredFiles: ['src/checkout.ts'],
expectedFiles: ['src/checkout.ts'],
forbiddenFiles: ['src/checkout.test.ts', 'src/catalog.ts', 'src/user.ts', 'package.json'],
transcriptAssertions: ['"type":"message_complete"'],
},
},
{
id: 'permission-artifact',
title: 'Create a required project artifact',
description: 'A test expects a generated summary file. The Agent must create a new file and validate it without changing the test.',
fixture: 'scripts/quality-gate/baseline/fixtures/permission-artifact',
prompt: 'Run the tests, create the missing project artifact they require, and rerun the tests. Do not modify the tests or package.json.',
mode: 'websocket',
requiredCapabilities: ['model', 'file-edit', 'shell', 'permission'],
timeoutMs: 180_000,
verify: {
commands: [['bun', 'test']],
requiredFiles: ['notes/summary.md'],
expectedFiles: ['notes/summary.md'],
forbiddenFiles: ['src/artifact.test.ts', 'package.json'],
transcriptAssertions: ['"type":"message_complete"'],
},
},
{
id: 'cross-module-refactor',
title: 'Complete a cross-module parser refactor',
description: 'A config parser contract changed but the implementation and caller are stale. The Agent must update multiple modules coherently.',
fixture: 'scripts/quality-gate/baseline/fixtures/cross-module-refactor',
prompt: 'Update the config parsing flow so parseConfig returns a structured object with enabled and retries fields. Update the implementation and caller so the existing tests pass, then run the suite.',
mode: 'websocket',
requiredCapabilities: ['model', 'file-edit', 'shell'],
timeoutMs: 300_000,
verify: {
commands: [['bun', 'test']],
requiredFiles: ['src/config.ts', 'src/runner.ts'],
expectedFiles: ['src/config.ts', 'src/runner.ts', 'src/runner.test.ts'],
forbiddenFiles: ['package.json'],
transcriptAssertions: ['"type":"message_complete"'],
},
},
]
export function validateBaselineCases(cases = baselineCases) {
const ids = new Set<string>()
for (const testCase of cases) {
if (ids.has(testCase.id)) {
throw new Error(`Duplicate baseline case id: ${testCase.id}`)
}
ids.add(testCase.id)
if (!testCase.fixture) {
throw new Error(`Baseline case ${testCase.id} is missing a fixture`)
}
if (testCase.verify.commands.length === 0) {
throw new Error(`Baseline case ${testCase.id} is missing verification commands`)
}
if (testCase.timeoutMs < 30_000) {
throw new Error(`Baseline case ${testCase.id} timeout is too low`)
}
}
}

View File

@ -0,0 +1,327 @@
import { appendFileSync, cpSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from 'node:net'
import type { BaselineCase, BaselineTarget, LaneResult } from '../types'
type ServerMessage = {
type: string
requestId?: string
message?: string
code?: string
[key: string]: unknown
}
function getPort() {
return new Promise<number>((resolve, reject) => {
const server = createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
const port = typeof address === 'object' && address ? address.port : 0
server.close(() => resolve(port))
})
})
}
async function waitForHttp(url: string, timeoutMs: number) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const res = await fetch(url)
if (res.ok) return
} catch {
// Keep polling until timeout.
}
await Bun.sleep(500)
}
throw new Error(`Timed out waiting for ${url}`)
}
async function runCommand(command: string[], cwd: string) {
const proc = Bun.spawn(command, {
cwd,
stdout: 'pipe',
stderr: 'pipe',
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
return { exitCode, stdout, stderr }
}
function listFiles(root: string, current = root): string[] {
const files: string[] = []
for (const entry of readdirSync(current)) {
if (entry === 'node_modules' || entry === '.git') {
continue
}
const fullPath = join(current, entry)
const stat = statSync(fullPath)
if (stat.isDirectory()) {
files.push(...listFiles(root, fullPath))
continue
}
if (stat.isFile()) {
files.push(fullPath.slice(root.length + 1))
}
}
return files.sort()
}
export function changedFiles(beforeDir: string, afterDir: string) {
const before = new Set(listFiles(beforeDir))
const after = new Set(listFiles(afterDir))
const changed = new Set<string>()
for (const file of before) {
if (!after.has(file)) {
changed.add(file)
continue
}
if (!readFileSync(join(beforeDir, file)).equals(readFileSync(join(afterDir, file)))) {
changed.add(file)
}
}
for (const file of after) {
if (!before.has(file)) {
changed.add(file)
}
}
return [...changed].sort()
}
export async function writeDiffPatch(beforeDir: string, afterDir: string, patchPath: string) {
const result = await runCommand(['git', 'diff', '--no-index', '--', beforeDir, afterDir], process.cwd())
writeFileSync(patchPath, `${result.stdout}${result.stderr}`)
}
function verifyChangedFiles(testCase: BaselineCase, changed: string[]) {
const expected = testCase.verify.expectedFiles
if (expected) {
const unexpected = changed.filter((file) => !expected.includes(file))
if (unexpected.length > 0) {
throw new Error(`unexpected changed files: ${unexpected.join(', ')}`)
}
}
const required = testCase.verify.requiredFiles ?? []
const missing = required.filter((file) => !changed.includes(file))
if (missing.length > 0) {
throw new Error(`required files were not changed: ${missing.join(', ')}`)
}
const forbidden = testCase.verify.forbiddenFiles ?? []
const forbiddenChanged = forbidden.filter((file) => changed.includes(file))
if (forbiddenChanged.length > 0) {
throw new Error(`forbidden files changed: ${forbiddenChanged.join(', ')}`)
}
}
function verifyTranscript(testCase: BaselineCase, transcriptPath: string) {
const assertions = testCase.verify.transcriptAssertions ?? []
if (assertions.length === 0) {
return
}
const transcript = readFileSync(transcriptPath, 'utf8')
const missing = assertions.filter((assertion) => !transcript.includes(assertion))
if (missing.length > 0) {
throw new Error(`transcript assertions missing: ${missing.join(', ')}`)
}
}
async function pipeToFile(stream: ReadableStream<Uint8Array> | null, path: string) {
if (!stream) return
const reader = stream.getReader()
const decoder = new TextDecoder()
while (true) {
const { value, done } = await reader.read()
if (done) break
appendFileSync(path, decoder.decode(value, { stream: true }))
}
}
function waitForWebSocketOpen(ws: WebSocket) {
return new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve()
ws.onerror = () => reject(new Error('WebSocket failed to open'))
})
}
async function runPromptOverWebSocket(
baseUrl: string,
sessionId: string,
prompt: string,
timeoutMs: number,
target?: BaselineTarget,
) {
const wsUrl = baseUrl.replace(/^http/, 'ws')
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
const messages: ServerMessage[] = []
try {
await waitForWebSocketOpen(ws)
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Timed out waiting for baseline case completion after ${timeoutMs}ms`))
}, timeoutMs)
ws.onmessage = (event) => {
const message = JSON.parse(String(event.data)) as ServerMessage
messages.push(message)
if (message.type === 'connected') {
if (target && target.modelId !== 'current') {
ws.send(JSON.stringify({
type: 'set_runtime_config',
providerId: target.providerId,
modelId: target.modelId,
}))
}
ws.send(JSON.stringify({ type: 'user_message', content: prompt }))
return
}
if (message.type === 'permission_request' && typeof message.requestId === 'string') {
ws.send(JSON.stringify({
type: 'permission_response',
requestId: message.requestId,
allowed: true,
rule: 'baseline-run',
}))
return
}
if (message.type === 'message_complete') {
clearTimeout(timer)
resolve()
return
}
if (message.type === 'error') {
clearTimeout(timer)
reject(new Error(`${message.code ?? 'WS_ERROR'}: ${message.message ?? 'unknown error'}`))
}
}
ws.onerror = () => {
clearTimeout(timer)
reject(new Error('WebSocket error during baseline case'))
}
})
} finally {
ws.close()
}
return messages
}
export async function executeBaselineCase(
testCase: BaselineCase,
rootDir: string,
artifactDir: string,
target?: BaselineTarget,
): Promise<LaneResult> {
const started = Date.now()
const resultId = target
? `baseline:${testCase.id}:${target.label.replace(/[^a-zA-Z0-9._-]+/g, '-')}`
: `baseline:${testCase.id}`
const resultTitle = target ? `${testCase.title} (${target.label})` : testCase.title
mkdirSync(artifactDir, { recursive: true })
const port = await getPort()
const baseUrl = `http://127.0.0.1:${port}`
const workRoot = await mkdtemp(join(tmpdir(), `quality-gate-${testCase.id}-`))
const originalDir = join(workRoot, 'original')
const projectDir = join(workRoot, 'project')
cpSync(join(rootDir, testCase.fixture), originalDir, { recursive: true })
cpSync(join(rootDir, testCase.fixture), projectDir, { recursive: true })
const serverLogPath = join(artifactDir, 'server.log')
const transcriptPath = join(artifactDir, 'transcript.jsonl')
const verificationPath = join(artifactDir, 'verification.log')
const diffPath = join(artifactDir, 'diff.patch')
const server = Bun.spawn(['bun', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(port)], {
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
SERVER_PORT: String(port),
},
})
const stdoutPump = pipeToFile(server.stdout, serverLogPath)
const stderrPump = pipeToFile(server.stderr, serverLogPath)
try {
await waitForHttp(`${baseUrl}/health`, 60_000)
const createResponse = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: projectDir }),
})
if (!createResponse.ok) {
throw new Error(`Failed to create session: ${createResponse.status}`)
}
const session = await createResponse.json() as { sessionId?: string }
if (!session.sessionId) {
throw new Error('Session response did not include sessionId')
}
const messages = await runPromptOverWebSocket(baseUrl, session.sessionId, testCase.prompt, testCase.timeoutMs, target)
writeFileSync(transcriptPath, messages.map((message) => JSON.stringify(message)).join('\n') + '\n')
verifyTranscript(testCase, transcriptPath)
await writeDiffPatch(originalDir, projectDir, diffPath)
const changed = changedFiles(originalDir, projectDir)
verifyChangedFiles(testCase, changed)
let verificationLog = ''
for (const command of testCase.verify.commands) {
const result = await runCommand(command, projectDir)
verificationLog += `$ ${command.join(' ')}\n${result.stdout}${result.stderr}\n`
if (result.exitCode !== 0) {
writeFileSync(verificationPath, verificationLog)
return {
id: resultId,
title: resultTitle,
status: 'failed',
durationMs: Date.now() - started,
exitCode: result.exitCode,
error: `verification command failed: ${command.join(' ')}`,
artifactDir,
}
}
}
writeFileSync(verificationPath, verificationLog)
return {
id: resultId,
title: resultTitle,
status: 'passed',
durationMs: Date.now() - started,
artifactDir,
}
} catch (error) {
return {
id: resultId,
title: resultTitle,
status: 'failed',
durationMs: Date.now() - started,
error: error instanceof Error ? error.message : String(error),
artifactDir,
}
} finally {
server.kill()
await server.exited.catch(() => undefined)
await Promise.all([stdoutPump, stderrPump]).catch(() => undefined)
rmSync(workRoot, { recursive: true, force: true })
}
}

View File

@ -0,0 +1,8 @@
{
"name": "quality-gate-cross-module-refactor-fixture",
"private": true,
"type": "module",
"scripts": {
"test": "bun test"
}
}

View File

@ -0,0 +1,3 @@
export function parseConfig(raw: string) {
return raw === 'enabled'
}

View File

@ -0,0 +1,17 @@
import { describe, expect, test } from 'bun:test'
import { parseConfig } from './config'
import { describeRun } from './runner'
describe('config runner', () => {
test('parses structured config', () => {
expect(parseConfig('enabled retries=3')).toEqual({ enabled: true, retries: 3 })
})
test('describes enabled retry runs', () => {
expect(describeRun('enabled retries=3')).toBe('enabled with 3 retries')
})
test('describes disabled runs', () => {
expect(describeRun('disabled retries=0')).toBe('disabled')
})
})

View File

@ -0,0 +1,6 @@
import { parseConfig } from './config'
export function describeRun(rawConfig: string) {
const enabled = parseConfig(rawConfig)
return enabled ? 'enabled' : 'disabled'
}

View File

@ -0,0 +1,8 @@
{
"name": "quality-gate-failing-unit-fixture",
"private": true,
"type": "module",
"scripts": {
"test": "bun test"
}
}

View File

@ -0,0 +1,8 @@
import { describe, expect, test } from 'bun:test'
import { add } from './math'
describe('add', () => {
test('adds positive numbers', () => {
expect(add(2, 3)).toBe(5)
})
})

View File

@ -0,0 +1,3 @@
export function add(a: number, b: number) {
return a - b
}

View File

@ -0,0 +1,8 @@
{
"name": "quality-gate-failure-recovery-fixture",
"private": true,
"type": "module",
"scripts": {
"test": "bun test"
}
}

View File

@ -0,0 +1,16 @@
import { describe, expect, test } from 'bun:test'
import { slugify } from './slug'
describe('slugify', () => {
test('normalizes whitespace and punctuation', () => {
expect(slugify(' Hello, Coding Agent! ')).toBe('hello-coding-agent')
})
test('collapses repeated separators', () => {
expect(slugify('Ship---Stable Releases')).toBe('ship-stable-releases')
})
test('trims separators from the edges', () => {
expect(slugify('---Quality Gate---')).toBe('quality-gate')
})
})

View File

@ -0,0 +1,3 @@
export function slugify(input: string) {
return input.toLowerCase().replaceAll(' ', '-')
}

View File

@ -0,0 +1,8 @@
{
"name": "quality-gate-multi-file-api-fixture",
"private": true,
"type": "module",
"scripts": {
"test": "bun test"
}
}

View File

@ -0,0 +1,14 @@
export type User = {
name: string
email: string
}
export type UserDisplay = {
displayName: string
}
export function formatUser(user: User) {
return {
displayName: user.name,
} satisfies UserDisplay
}

View File

@ -0,0 +1,15 @@
import { describe, expect, test } from 'bun:test'
import { formatUser } from './api'
import { renderUser } from './app'
describe('renderUser', () => {
test('renders name and email', () => {
expect(renderUser({ name: 'Ada Lovelace', email: 'ada@example.com' })).toBe('User: Ada Lovelace <ada@example.com>')
})
test('uses the structured display API contract', () => {
expect(formatUser({ name: 'Ada Lovelace', email: 'ada@example.com' })).toEqual({
label: 'Ada Lovelace <ada@example.com>',
})
})
})

View File

@ -0,0 +1,5 @@
import { formatUser, type User } from './api'
export function renderUser(user: User) {
return `User: ${formatUser(user).displayName}`
}

View File

@ -0,0 +1,8 @@
{
"name": "quality-gate-permission-artifact-fixture",
"private": true,
"type": "module",
"scripts": {
"test": "bun test"
}
}

View File

@ -0,0 +1,9 @@
import { describe, expect, test } from 'bun:test'
import { existsSync, readFileSync } from 'node:fs'
describe('release artifact', () => {
test('has the required summary marker', () => {
expect(existsSync('notes/summary.md')).toBe(true)
expect(readFileSync('notes/summary.md', 'utf8')).toContain('baseline-ready')
})
})

View File

@ -0,0 +1,8 @@
{
"name": "quality-gate-workspace-search-edit-fixture",
"private": true,
"type": "module",
"scripts": {
"test": "bun test"
}
}

View File

@ -0,0 +1,9 @@
export type CatalogItem = {
sku: string
priceCents: number
}
export const catalog: CatalogItem[] = [
{ sku: 'agent-seat', priceCents: 1200 },
{ sku: 'baseline-pack', priceCents: 800 },
]

View File

@ -0,0 +1,10 @@
import { describe, expect, test } from 'bun:test'
import { catalog } from './catalog'
import { calculateTotalCents } from './checkout'
import { testUser } from './user'
describe('calculateTotalCents', () => {
test('applies percentage discounts to the subtotal', () => {
expect(calculateTotalCents(catalog, testUser)).toBe(1500)
})
})

View File

@ -0,0 +1,7 @@
import type { CatalogItem } from './catalog'
import type { User } from './user'
export function calculateTotalCents(items: CatalogItem[], user: User) {
const subtotal = items.reduce((sum, item) => sum + item.priceCents, 0)
return subtotal - user.discountPercent
}

View File

@ -0,0 +1,9 @@
export type User = {
id: string
discountPercent: number
}
export const testUser: User = {
id: 'user-1',
discountPercent: 25,
}

View File

@ -0,0 +1,376 @@
import { appendFileSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtemp } from 'node:fs/promises'
import { createServer } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { changedFiles, writeDiffPatch } from '../baseline/execute'
import type { BaselineTarget, LaneResult } from '../types'
const FIXTURE = 'scripts/quality-gate/desktop-smoke/fixtures/chat-edit'
const PROMPT = [
'Run the tests in this project, fix the failing greeting implementation, and rerun the tests.',
'Only edit src/greeting.ts. Do not edit package.json or tests.',
'When the tests pass, briefly say done.',
].join(' ')
async function getPort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer()
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to allocate a local port')))
return
}
const port = address.port
server.close(() => resolve(port))
})
})
}
async function pipeToFile(stream: ReadableStream<Uint8Array> | null, path: string) {
if (!stream) return
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
appendFileSync(path, Buffer.from(value))
}
}
async function waitForHttp(url: string, timeoutMs: number) {
const deadline = Date.now() + timeoutMs
let lastError = ''
while (Date.now() < deadline) {
try {
const response = await fetch(url)
if (response.ok) return
lastError = `HTTP ${response.status}`
} catch (error) {
lastError = error instanceof Error ? error.message : String(error)
}
await Bun.sleep(500)
}
throw new Error(`Timed out waiting for ${url}${lastError ? ` (${lastError})` : ''}`)
}
async function runLoggedCommand(
command: string[],
options: {
cwd: string
logPath: string
env?: Record<string, string>
timeoutMs?: number
allowFailure?: boolean
maxLogChars?: number
},
) {
appendFileSync(options.logPath, `\n$ ${command.join(' ')}\n`)
const proc = Bun.spawn(command, {
cwd: options.cwd,
env: options.env ? { ...process.env, ...options.env } : process.env,
stdout: 'pipe',
stderr: 'pipe',
})
const outputPromise = Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
const timeout = options.timeoutMs
? Bun.sleep(options.timeoutMs).then(() => {
proc.kill()
throw new Error(`Command timed out after ${options.timeoutMs}ms: ${command.join(' ')}`)
})
: null
const [stdout, stderr, exitCode] = timeout
? await Promise.race([outputPromise, timeout])
: await outputPromise
const output = `${stdout}${stderr}`
appendFileSync(
options.logPath,
options.maxLogChars && output.length > options.maxLogChars
? `${output.slice(0, options.maxLogChars)}\n[quality-gate] output truncated at ${options.maxLogChars} chars\n`
: output,
)
if (exitCode !== 0 && !options.allowFailure) {
throw new Error(`Command failed (${exitCode}): ${command.join(' ')}`)
}
return { stdout, stderr, exitCode }
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init)
if (!response.ok) {
throw new Error(`${init?.method ?? 'GET'} ${url} failed with HTTP ${response.status}: ${await response.text()}`)
}
return response.json() as Promise<T>
}
async function setPermissionMode(baseUrl: string, mode: string) {
await fetchJson<{ ok: true; mode: string }>(`${baseUrl}/api/permissions/mode`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
})
}
async function waitForVerifiedProject(
browserEnv: Record<string, string>,
browserLogPath: string,
rootDir: string,
originalDir: string,
projectDir: string,
artifactDir: string,
timeoutMs: number,
) {
const deadline = Date.now() + timeoutMs
let lastVerificationError = 'project verification has not run yet'
while (Date.now() < deadline) {
const body = await runLoggedCommand(['agent-browser', 'get', 'text', '#content-area'], {
cwd: rootDir,
env: browserEnv,
logPath: browserLogPath,
timeoutMs: 15_000,
allowFailure: true,
maxLogChars: 4_000,
})
const browserText = `${body.stdout}\n${body.stderr}`
if (browserText.includes('CLI_START_FAILED') || browserText.includes('CLI_RESTART_FAILED')) {
throw new Error('Desktop session reported a CLI startup failure')
}
if (
browserText.includes('API Error: 429') ||
browserText.includes('AccountQuotaExceeded') ||
browserText.includes('TooManyRequests')
) {
throw new Error('Desktop session reported provider quota/rate-limit failure')
}
if (browserText.includes('处理过程中发生错误') || browserText.includes('API Error:')) {
throw new Error('Desktop session reported an API error')
}
try {
await verifyProject(originalDir, projectDir, artifactDir)
return
} catch (error) {
lastVerificationError = error instanceof Error ? error.message : String(error)
}
await Bun.sleep(5_000)
}
throw new Error(`Timed out waiting for desktop project verification: ${lastVerificationError}`)
}
async function verifyProject(originalDir: string, projectDir: string, artifactDir: string) {
await writeDiffPatch(originalDir, projectDir, join(artifactDir, 'diff.patch'))
const changed = changedFiles(originalDir, projectDir)
const unexpected = changed.filter((file) => file !== 'src/greeting.ts')
if (unexpected.length > 0) {
throw new Error(`desktop smoke changed unexpected files: ${unexpected.join(', ')}`)
}
if (!changed.includes('src/greeting.ts')) {
throw new Error('desktop smoke did not change src/greeting.ts')
}
const implementation = readFileSync(join(projectDir, 'src/greeting.ts'), 'utf8')
if (!implementation.includes('from desktop smoke!')) {
throw new Error('desktop smoke implementation is missing the expected marker text')
}
const proc = Bun.spawn(['bun', 'test'], {
cwd: projectDir,
stdout: 'pipe',
stderr: 'pipe',
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
writeFileSync(join(artifactDir, 'verification.log'), `${stdout}${stderr}`)
if (exitCode !== 0) {
throw new Error(`desktop smoke verification failed with exit code ${exitCode}`)
}
}
export async function executeDesktopSmoke(
rootDir: string,
artifactDir: string,
resultId: string,
resultTitle: string,
target: BaselineTarget | undefined,
): Promise<LaneResult> {
const started = Date.now()
mkdirSync(artifactDir, { recursive: true })
const serverLogPath = join(artifactDir, 'server.log')
const viteLogPath = join(artifactDir, 'vite.log')
const browserLogPath = join(artifactDir, 'browser.log')
const workRoot = await mkdtemp(join(tmpdir(), 'quality-gate-desktop-smoke-'))
const originalDir = join(workRoot, 'original')
const projectDir = join(workRoot, 'project')
const browserProfileDir = join(workRoot, 'browser-profile')
cpSync(join(rootDir, FIXTURE), originalDir, { recursive: true })
cpSync(join(rootDir, FIXTURE), projectDir, { recursive: true })
const serverPort = await getPort()
const vitePort = await getPort()
const baseUrl = `http://127.0.0.1:${serverPort}`
const appUrl = `http://127.0.0.1:${vitePort}`
const sessionName = `quality-gate-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const browserEnv = {
AGENT_BROWSER_SESSION: sessionName,
AGENT_BROWSER_PROFILE: browserProfileDir,
}
const server = Bun.spawn(['bun', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(serverPort)], {
cwd: rootDir,
stdout: 'pipe',
stderr: 'pipe',
})
void pipeToFile(server.stdout, serverLogPath)
void pipeToFile(server.stderr, serverLogPath)
const vite = Bun.spawn(['bun', 'run', 'dev', '--', '--host', '127.0.0.1', '--port', String(vitePort), '--strictPort'], {
cwd: join(rootDir, 'desktop'),
env: {
...process.env,
VITE_DESKTOP_SERVER_URL: baseUrl,
},
stdout: 'pipe',
stderr: 'pipe',
})
void pipeToFile(vite.stdout, viteLogPath)
void pipeToFile(vite.stderr, viteLogPath)
let previousPermissionMode: string | null = null
try {
await waitForHttp(`${baseUrl}/health`, 20_000)
await waitForHttp(appUrl, 30_000)
const permission = await fetchJson<{ mode: string }>(`${baseUrl}/api/permissions/mode`)
previousPermissionMode = permission.mode
await setPermissionMode(baseUrl, 'bypassPermissions')
const session = await fetchJson<{ sessionId: string }>(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: projectDir }),
})
const runtimeSelection = {
providerId: target?.providerId ?? null,
modelId: target?.modelId ?? 'current',
}
await runLoggedCommand(['agent-browser', 'open', appUrl], {
cwd: rootDir,
env: browserEnv,
logPath: browserLogPath,
timeoutMs: 30_000,
})
await runLoggedCommand([
'agent-browser',
'eval',
[
`localStorage.setItem('cc-haha-open-tabs', ${JSON.stringify(JSON.stringify({
openTabs: [{ sessionId: session.sessionId, title: 'Desktop Smoke', type: 'session' }],
activeTabId: session.sessionId,
}))})`,
`localStorage.setItem('cc-haha-session-runtime', ${JSON.stringify(JSON.stringify({
[session.sessionId]: runtimeSelection,
}))})`,
].join(';'),
], {
cwd: rootDir,
env: browserEnv,
logPath: browserLogPath,
timeoutMs: 15_000,
})
await runLoggedCommand(['agent-browser', 'reload'], {
cwd: rootDir,
env: browserEnv,
logPath: browserLogPath,
timeoutMs: 30_000,
})
await runLoggedCommand(['agent-browser', 'wait', 'textarea'], {
cwd: rootDir,
env: browserEnv,
logPath: browserLogPath,
timeoutMs: 30_000,
})
await runLoggedCommand(['agent-browser', 'screenshot', join(artifactDir, 'initial.png')], {
cwd: rootDir,
env: browserEnv,
logPath: browserLogPath,
timeoutMs: 20_000,
allowFailure: true,
})
await runLoggedCommand(['agent-browser', 'fill', 'textarea', PROMPT], {
cwd: rootDir,
env: browserEnv,
logPath: browserLogPath,
timeoutMs: 20_000,
})
await runLoggedCommand(['agent-browser', 'press', 'Enter'], {
cwd: rootDir,
env: browserEnv,
logPath: browserLogPath,
timeoutMs: 15_000,
})
await waitForVerifiedProject(
browserEnv,
browserLogPath,
rootDir,
originalDir,
projectDir,
artifactDir,
360_000,
)
await runLoggedCommand(['agent-browser', 'screenshot', join(artifactDir, 'final.png')], {
cwd: rootDir,
env: browserEnv,
logPath: browserLogPath,
timeoutMs: 20_000,
allowFailure: true,
})
return {
id: resultId,
title: resultTitle,
status: 'passed',
durationMs: Date.now() - started,
artifactDir,
}
} catch (error) {
return {
id: resultId,
title: resultTitle,
status: 'failed',
durationMs: Date.now() - started,
error: error instanceof Error ? error.message : String(error),
artifactDir,
}
} finally {
if (previousPermissionMode) {
await setPermissionMode(baseUrl, previousPermissionMode).catch((error) => {
appendFileSync(serverLogPath, `\n[quality-gate] Failed to restore permission mode: ${error instanceof Error ? error.message : String(error)}\n`)
})
}
await runLoggedCommand(['agent-browser', 'close'], {
cwd: rootDir,
env: browserEnv,
logPath: browserLogPath,
timeoutMs: 10_000,
allowFailure: true,
}).catch(() => {})
server.kill()
vite.kill()
rmSync(workRoot, { recursive: true, force: true })
}
}

View File

@ -0,0 +1,8 @@
{
"name": "desktop-smoke-chat-edit",
"private": true,
"type": "module",
"scripts": {
"test": "bun test"
}
}

View File

@ -0,0 +1,8 @@
import { describe, expect, test } from 'bun:test'
import { greetingFor } from './greeting'
describe('greetingFor', () => {
test('includes the desktop smoke marker', () => {
expect(greetingFor('Ada Lovelace')).toBe('Hello, Ada Lovelace from desktop smoke!')
})
})

View File

@ -0,0 +1,3 @@
export function greetingFor(name: string) {
return `Hello, ${name}`
}

View File

@ -0,0 +1,79 @@
#!/usr/bin/env bun
import { runQualityGate } from './runner'
import { formatProviderTargets, loadProviderIndex, parseBaselineTargetValues } from './providerTargets'
import type { BaselineTarget, QualityGateMode } from './types'
function parseArgs(argv: string[]) {
const args = new Map<string, Array<string | boolean>>()
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (!arg.startsWith('--')) {
continue
}
const next = argv[index + 1]
if (next && !next.startsWith('--')) {
args.set(arg, [...(args.get(arg) ?? []), next])
index += 1
} else {
args.set(arg, [...(args.get(arg) ?? []), true])
}
}
return args
}
function firstArg(args: Map<string, Array<string | boolean>>, name: string) {
return args.get(name)?.[0]
}
function hasFlag(args: Map<string, Array<string | boolean>>, name: string) {
return args.has(name)
}
function readMode(value: string | boolean | undefined): QualityGateMode {
if (value === 'pr' || value === 'baseline' || value === 'release') {
return value
}
throw new Error('Usage: bun run quality:gate --mode <pr|baseline|release> [--dry-run] [--allow-live] [--provider-model provider:model[:label]]')
}
function readBaselineTargets(args: Map<string, Array<string | boolean>>): BaselineTarget[] {
const values = (args.get('--provider-model') ?? [])
.filter((value): value is string => typeof value === 'string')
return parseBaselineTargetValues(values)
}
const args = parseArgs(process.argv.slice(2))
if (hasFlag(args, '--list-providers')) {
console.log(formatProviderTargets(loadProviderIndex()))
process.exit(0)
}
const mode = readMode(firstArg(args, '--mode'))
const dryRun = hasFlag(args, '--dry-run')
const allowLive = hasFlag(args, '--allow-live')
const artifactsDir = typeof firstArg(args, '--artifacts-dir') === 'string'
? String(firstArg(args, '--artifacts-dir'))
: undefined
const baselineTargets = readBaselineTargets(args)
const { report, outputDir } = await runQualityGate({
mode,
dryRun,
allowLive,
baselineTargets,
rootDir: process.cwd(),
artifactsDir,
})
console.log(`Quality gate report: ${outputDir}/report.md`)
console.log(`Summary: passed=${report.summary.passed} failed=${report.summary.failed} skipped=${report.summary.skipped}`)
if (report.summary.failed > 0) {
process.exit(1)
}

View File

@ -0,0 +1,74 @@
import { baselineCases } from './baseline/cases'
import type { BaselineTarget, LaneDefinition, QualityGateMode } from './types'
export function lanesForMode(mode: QualityGateMode, baselineTargets: BaselineTarget[] = []): LaneDefinition[] {
const lanes: LaneDefinition[] = [
{
id: 'impact-report',
title: 'Impact report',
description: 'Summarize changed areas, required local checks, and risk notes.',
kind: 'command',
command: ['bun', 'run', 'check:impact'],
requiredForModes: ['pr', 'baseline', 'release'],
},
{
id: 'pr-checks',
title: 'Path-aware PR checks',
description: 'Run the existing local PR gate with stable path-aware checks.',
kind: 'command',
command: ['bun', 'run', 'check:pr'],
requiredForModes: ['pr', 'release'],
},
{
id: 'baseline-catalog',
title: 'Baseline case catalog validation',
description: 'Validate real Coding Agent baseline case definitions and fixture metadata.',
kind: 'command',
command: ['bun', 'test', 'scripts/quality-gate/baseline/cases.test.ts'],
requiredForModes: ['baseline', 'release'],
},
{
id: 'native-checks',
title: 'Native desktop checks',
description: 'Build sidecars and run the Tauri native compile check.',
kind: 'command',
command: ['bun', 'run', 'check:native'],
requiredForModes: ['release'],
},
]
const targets = baselineTargets.length > 0
? baselineTargets
: [{ providerId: null, modelId: 'current', label: 'current-runtime' }]
for (const testCase of baselineCases) {
for (const target of targets) {
const targetSlug = target.label.replace(/[^a-zA-Z0-9._-]+/g, '-')
lanes.push({
id: `baseline:${testCase.id}:${targetSlug}`,
title: `${testCase.title} (${target.label})`,
description: testCase.description,
kind: 'baseline-case',
baselineCaseId: testCase.id,
baselineTarget: target,
requiredForModes: ['baseline', 'release'],
live: true,
})
}
}
for (const target of targets) {
const targetSlug = target.label.replace(/[^a-zA-Z0-9._-]+/g, '-')
lanes.push({
id: `desktop-smoke:agent-browser-chat:${targetSlug}`,
title: `Desktop agent-browser chat smoke (${target.label})`,
description: 'Open the desktop web app with agent-browser, send a real chat task, and verify the model edits a fixture project through the UI.',
kind: 'desktop-smoke',
baselineTarget: target,
requiredForModes: ['baseline', 'release'],
live: true,
})
}
return lanes.filter((lane) => lane.requiredForModes.includes(mode))
}

View File

@ -0,0 +1,171 @@
import { describe, expect, test } from 'bun:test'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
formatProviderTargets,
loadProviderIndex,
parseBaselineTargetValues,
} from './providerTargets'
function withProviderIndex(index: unknown, fn: (configDir: string) => void) {
const configDir = join(tmpdir(), `quality-provider-targets-${crypto.randomUUID()}`)
mkdirSync(join(configDir, 'cc-haha'), { recursive: true })
writeFileSync(join(configDir, 'cc-haha', 'providers.json'), JSON.stringify(index, null, 2))
try {
fn(configDir)
} finally {
rmSync(configDir, { recursive: true, force: true })
}
}
describe('quality gate provider targets', () => {
test('lists copyable provider-model selectors without exposing keys', () => {
withProviderIndex({
activeId: 'provider-kimi',
providers: [
{
id: 'provider-kimi',
presetId: 'volcengine',
name: 'Volcengine Codingplan',
apiKey: 'secret-key',
baseUrl: 'https://example.invalid',
apiFormat: 'openai_chat',
models: {
main: 'kimi-k2.6',
haiku: 'kimi-k2.6',
sonnet: 'kimi-k2.6-thinking',
opus: 'kimi-k2.6-thinking',
},
},
],
}, (configDir) => {
const output = formatProviderTargets(loadProviderIndex(configDir), configDir)
expect(output).toContain('Volcengine Codingplan')
expect(output).toContain('--provider-model volcengine-codingplan:main:volcengine-codingplan-main')
expect(output).toContain('--provider-model volcengine-codingplan:sonnet:volcengine-codingplan-sonnet')
expect(output).not.toContain('secret-key')
})
})
test('parses provider-model by provider slug instead of requiring UUID', () => {
withProviderIndex({
activeId: null,
providers: [
{
id: 'provider-minimax',
presetId: 'minimax',
name: 'MiniMax',
apiKey: 'secret-key',
baseUrl: 'https://example.invalid',
apiFormat: 'openai_chat',
models: {
main: 'MiniMax-M2.7-highspeed',
haiku: 'MiniMax-M2.7-highspeed',
sonnet: 'MiniMax-M2.7-highspeed',
opus: 'MiniMax-M2.7-highspeed',
},
},
],
}, (configDir) => {
const targets = parseBaselineTargetValues(
['minimax:MiniMax-M2.7-highspeed'],
loadProviderIndex(configDir),
)
expect(targets).toEqual([
{
providerId: 'provider-minimax',
modelId: 'MiniMax-M2.7-highspeed',
label: 'minimax-MiniMax-M2.7-highspeed',
},
])
})
})
test('parses provider-model by role so model IDs may contain colons', () => {
withProviderIndex({
activeId: null,
providers: [
{
id: 'provider-anthropic-proxy',
presetId: 'proxy',
name: 'Anthropic Proxy',
apiKey: 'secret-key',
baseUrl: 'https://example.invalid',
apiFormat: 'anthropic',
models: {
main: 'anthropic/claude-sonnet-4.6',
haiku: 'anthropic/claude-haiku-4.5:thinking',
sonnet: 'anthropic/claude-sonnet-4.6',
opus: 'anthropic/claude-opus-4.7',
},
},
],
}, (configDir) => {
const targets = parseBaselineTargetValues(
['anthropic-proxy:haiku:proxy-haiku-thinking'],
loadProviderIndex(configDir),
)
expect(targets).toEqual([
{
providerId: 'provider-anthropic-proxy',
modelId: 'anthropic/claude-haiku-4.5:thinking',
label: 'proxy-haiku-thinking',
},
])
})
})
test('does not treat shared preset ids as provider selectors', () => {
withProviderIndex({
activeId: null,
providers: [
{
id: 'provider-one',
presetId: 'custom',
name: 'Provider One',
apiKey: 'secret-key',
baseUrl: 'https://example.invalid',
apiFormat: 'anthropic',
models: { main: 'model-one', haiku: 'model-one', sonnet: 'model-one', opus: 'model-one' },
},
{
id: 'provider-two',
presetId: 'custom',
name: 'Provider Two',
apiKey: 'secret-key',
baseUrl: 'https://example.invalid',
apiFormat: 'anthropic',
models: { main: 'model-two', haiku: 'model-two', sonnet: 'model-two', opus: 'model-two' },
},
],
}, (configDir) => {
expect(() => parseBaselineTargetValues(['custom:main'], loadProviderIndex(configDir))).toThrow('Unknown provider')
expect(parseBaselineTargetValues(['provider-one:main'], loadProviderIndex(configDir))[0]).toEqual({
providerId: 'provider-one',
modelId: 'model-one',
label: 'provider-one-model-one',
})
})
})
test('keeps current runtime and explicit UUID selectors supported', () => {
const targets = parseBaselineTargetValues([
'current:current',
'11111111-1111-4111-8111-111111111111:model-a:model-a-live',
])
expect(targets).toEqual([
{ providerId: null, modelId: 'current', label: 'current-runtime' },
{
providerId: '11111111-1111-4111-8111-111111111111',
modelId: 'model-a',
label: 'model-a-live',
},
])
})
})

View File

@ -0,0 +1,257 @@
import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { BaselineTarget } from './types'
type ProviderModels = {
main?: string
haiku?: string
sonnet?: string
opus?: string
}
type SavedProviderIndexEntry = {
id: string
name: string
presetId?: string
models?: ProviderModels
}
export type ProviderIndex = {
activeId: string | null
providers: SavedProviderIndexEntry[]
}
const DEFAULT_INDEX: ProviderIndex = { activeId: null, providers: [] }
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export function getProviderIndexPath(configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')) {
return join(configDir, 'cc-haha', 'providers.json')
}
export function slugifyProviderName(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function slugifyLabel(value: string) {
return value
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function providerAliases(provider: SavedProviderIndexEntry) {
return new Set([
provider.id,
provider.name,
provider.name.toLowerCase(),
slugifyProviderName(provider.name),
].filter(Boolean))
}
function modelEntries(provider: SavedProviderIndexEntry) {
const roles = [
['main', provider.models?.main],
['haiku', provider.models?.haiku],
['sonnet', provider.models?.sonnet],
['opus', provider.models?.opus],
] as const
const seen = new Set<string>()
return roles
.filter((entry): entry is [typeof entry[0], string] => typeof entry[1] === 'string' && entry[1].trim().length > 0)
.filter(([, modelId]) => {
if (seen.has(modelId)) return false
seen.add(modelId)
return true
})
}
function splitProviderTarget(value: string) {
const separator = value.indexOf(':')
if (separator === -1) {
return null
}
return {
providerRef: value.slice(0, separator),
modelAndLabel: value.slice(separator + 1),
}
}
function splitModelAndLabel(value: string) {
const separator = value.lastIndexOf(':')
if (separator === -1) {
return { modelId: value, label: undefined }
}
return {
modelId: value.slice(0, separator),
label: value.slice(separator + 1) || undefined,
}
}
function resolveProviderModel(provider: SavedProviderIndexEntry, modelAndLabel: string) {
const roleEntries = [
['main', provider.models?.main],
['haiku', provider.models?.haiku],
['sonnet', provider.models?.sonnet],
['opus', provider.models?.opus],
] as const
for (const [role, modelId] of roleEntries) {
if (!modelId) continue
if (modelAndLabel === role) {
return { modelId, label: undefined }
}
if (modelAndLabel.startsWith(`${role}:`)) {
return { modelId, label: modelAndLabel.slice(role.length + 1) || undefined }
}
}
const knownModels = [...new Set(roleEntries.map(([, modelId]) => modelId).filter((modelId): modelId is string => Boolean(modelId)))]
.sort((left, right) => right.length - left.length)
for (const modelId of knownModels) {
if (modelAndLabel === modelId) {
return { modelId, label: undefined }
}
if (modelAndLabel.startsWith(`${modelId}:`)) {
return { modelId, label: modelAndLabel.slice(modelId.length + 1) || undefined }
}
}
return splitModelAndLabel(modelAndLabel)
}
export function loadProviderIndex(configDir?: string): ProviderIndex {
const filePath = getProviderIndexPath(configDir)
if (!existsSync(filePath)) {
return { ...DEFAULT_INDEX, providers: [] }
}
const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial<ProviderIndex>
return {
activeId: typeof parsed.activeId === 'string' ? parsed.activeId : null,
providers: Array.isArray(parsed.providers)
? parsed.providers.filter((provider): provider is SavedProviderIndexEntry => (
provider &&
typeof provider.id === 'string' &&
typeof provider.name === 'string'
))
: [],
}
}
function resolveProviderReference(reference: string, index: ProviderIndex) {
if (reference === 'current' || reference === 'official') {
return null
}
const normalized = reference.toLowerCase()
const slug = slugifyProviderName(reference)
const matches = index.providers.filter((provider) => {
const aliases = providerAliases(provider)
return aliases.has(reference) || aliases.has(normalized) || aliases.has(slug)
})
if (matches.length === 1) {
return matches[0]
}
if (matches.length > 1) {
throw new Error(`Ambiguous provider reference "${reference}". Use one of these IDs: ${matches.map((provider) => provider.id).join(', ')}`)
}
if (UUID_PATTERN.test(reference)) {
return { id: reference, name: reference }
}
throw new Error(`Unknown provider "${reference}". Run "bun run quality:providers" to list available --provider-model values.`)
}
export function parseBaselineTargetValues(values: string[], index: ProviderIndex = loadProviderIndex()): BaselineTarget[] {
return values
.flatMap((value) => value.split(','))
.map((value) => value.trim())
.filter(Boolean)
.map((value) => {
const target = splitProviderTarget(value)
if (!target?.providerRef || !target.modelAndLabel) {
throw new Error(`Invalid --provider-model value "${value}". Expected provider:model[:label]. Run "bun run quality:providers" for copyable values.`)
}
const { providerRef, modelAndLabel } = target
const provider = resolveProviderReference(providerRef, index)
if (!provider) {
const { modelId, label } = splitModelAndLabel(modelAndLabel)
return {
providerId: null,
modelId,
label: label || (providerRef === 'current' && modelId === 'current' ? 'current-runtime' : `${providerRef}-${modelId}`),
}
}
const { modelId, label } = resolveProviderModel(provider, modelAndLabel)
const providerSlug = slugifyProviderName(provider.name) || provider.id.slice(0, 8)
return {
providerId: provider.id,
modelId,
label: label || slugifyLabel(`${providerSlug}-${modelId}`),
}
})
}
function providerSelector(provider: SavedProviderIndexEntry, index: ProviderIndex) {
const slug = slugifyProviderName(provider.name)
if (!slug) {
return provider.id
}
const matchingSlugCount = index.providers.filter((candidate) => slugifyProviderName(candidate.name) === slug).length
return matchingSlugCount === 1 ? slug : provider.id
}
export function formatProviderTargets(index: ProviderIndex = loadProviderIndex(), configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')) {
const lines = [
'Quality gate provider targets',
`Config: ${getProviderIndexPath(configDir)}`,
'',
'Current/default runtime:',
' --provider-model current:current:current-runtime',
'',
]
if (index.providers.length === 0) {
lines.push('Saved providers: none')
lines.push('')
lines.push('Add a provider in Desktop Settings > Providers, then run this command again.')
return lines.join('\n')
}
lines.push('Saved providers:')
for (const provider of index.providers) {
const providerSlug = providerSelector(provider, index)
const active = index.activeId === provider.id ? ' (active)' : ''
lines.push(` ${provider.name}${active}`)
lines.push(` id: ${provider.id}`)
lines.push(` selector: ${providerSlug}`)
const models = modelEntries(provider)
if (models.length === 0) {
lines.push(' models: none configured')
continue
}
for (const [role, modelId] of models) {
const label = slugifyLabel(`${providerSlug}-${role}`)
lines.push(` ${role}: ${modelId}`)
lines.push(` --provider-model ${providerSlug}:${role}:${label}`)
}
}
return lines.join('\n')
}

View File

@ -0,0 +1,5 @@
#!/usr/bin/env bun
import { formatProviderTargets, loadProviderIndex } from './providerTargets'
console.log(formatProviderTargets(loadProviderIndex()))

View File

@ -0,0 +1,39 @@
{
"quarantined": [
{
"id": "server:cron-scheduler",
"path": "src/server/__tests__/cron-scheduler.test.ts",
"reason": "Known unstable scheduler timing assertions; keep out of default server gate until fixed.",
"owner": "maintainers",
"reviewAfter": "2026-06-01"
},
{
"id": "server:providers-real",
"path": "src/server/__tests__/providers-real.test.ts",
"reason": "Live provider coverage belongs in maintainer baseline or release mode, not the default PR gate.",
"owner": "maintainers",
"reviewAfter": "2026-06-01"
},
{
"id": "server:tasks",
"path": "src/server/__tests__/tasks.test.ts",
"reason": "Known default-gate instability; keep quarantined until task timing is hardened.",
"owner": "maintainers",
"reviewAfter": "2026-06-01"
},
{
"id": "server:e2e:business-flow",
"path": "src/server/__tests__/e2e/business-flow.test.ts",
"reason": "Broad e2e flow is not deterministic enough for default PR checks.",
"owner": "maintainers",
"reviewAfter": "2026-06-01"
},
{
"id": "server:e2e:full-flow",
"path": "src/server/__tests__/e2e/full-flow.test.ts",
"reason": "Broad e2e flow is not deterministic enough for default PR checks.",
"owner": "maintainers",
"reviewAfter": "2026-06-01"
}
]
}

View File

@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test'
import { loadQuarantineManifest, quarantinedPathSet, validateQuarantineManifest } from './quarantine'
describe('quarantine manifest', () => {
test('loads the default manifest', () => {
const manifest = loadQuarantineManifest()
expect(manifest.quarantined.length).toBeGreaterThan(0)
})
test('exposes quarantined paths as a set', () => {
const paths = quarantinedPathSet()
expect(paths.has('src/server/__tests__/providers-real.test.ts')).toBe(true)
})
test('rejects duplicate ids', () => {
expect(() => validateQuarantineManifest({
quarantined: [
{
id: 'duplicate',
path: 'a.test.ts',
reason: 'test',
owner: 'maintainers',
reviewAfter: '2026-06-01',
},
{
id: 'duplicate',
path: 'b.test.ts',
reason: 'test',
owner: 'maintainers',
reviewAfter: '2026-06-01',
},
],
})).toThrow('duplicate quarantine id')
})
})

View File

@ -0,0 +1,50 @@
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
export type QuarantineEntry = {
id: string
path: string
reason: string
owner: string
reviewAfter: string
}
export type QuarantineManifest = {
quarantined: QuarantineEntry[]
}
const defaultManifestPath = join(dirname(fileURLToPath(import.meta.url)), 'quarantine.json')
export function loadQuarantineManifest(path = defaultManifestPath): QuarantineManifest {
const manifest = JSON.parse(readFileSync(path, 'utf8')) as QuarantineManifest
validateQuarantineManifest(manifest)
return manifest
}
export function validateQuarantineManifest(manifest: QuarantineManifest) {
if (!Array.isArray(manifest.quarantined)) {
throw new Error('quarantine manifest must contain a quarantined array')
}
const ids = new Set<string>()
const paths = new Set<string>()
for (const entry of manifest.quarantined) {
if (!entry.id || !entry.path || !entry.reason || !entry.owner || !entry.reviewAfter) {
throw new Error(`invalid quarantine entry: ${JSON.stringify(entry)}`)
}
if (ids.has(entry.id)) {
throw new Error(`duplicate quarantine id: ${entry.id}`)
}
if (paths.has(entry.path)) {
throw new Error(`duplicate quarantine path: ${entry.path}`)
}
ids.add(entry.id)
paths.add(entry.path)
}
}
export function quarantinedPathSet(manifest = loadQuarantineManifest()) {
return new Set(manifest.quarantined.map((entry) => entry.path))
}

View File

@ -0,0 +1,59 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { QualityGateReport } from './types'
export function writeReport(report: QualityGateReport, outputDir: string) {
mkdirSync(outputDir, { recursive: true })
writeFileSync(join(outputDir, 'report.json'), JSON.stringify(report, null, 2) + '\n')
writeFileSync(join(outputDir, 'report.md'), renderMarkdownReport(report))
}
export function renderMarkdownReport(report: QualityGateReport) {
const lines = [
`# Quality Gate Report`,
'',
`- Run: ${report.runId}`,
`- Mode: ${report.mode}`,
`- Dry run: ${report.dryRun ? 'yes' : 'no'}`,
`- Live checks allowed: ${report.allowLive ? 'yes' : 'no'}`,
`- Git SHA: ${report.git.sha ?? 'unknown'}`,
`- Dirty worktree: ${report.git.dirty ? 'yes' : 'no'}`,
`- Started: ${report.startedAt}`,
`- Finished: ${report.finishedAt}`,
'',
`## Summary`,
'',
`- Passed: ${report.summary.passed}`,
`- Failed: ${report.summary.failed}`,
`- Skipped: ${report.summary.skipped}`,
'',
`## Lanes`,
'',
]
for (const result of report.results) {
lines.push(`### ${result.title}`)
lines.push('')
lines.push(`- ID: ${result.id}`)
lines.push(`- Status: ${result.status}`)
lines.push(`- Duration: ${result.durationMs}ms`)
if (result.command) {
lines.push(`- Command: \`${result.command.join(' ')}\``)
}
if (result.exitCode !== undefined) {
lines.push(`- Exit code: ${result.exitCode}`)
}
if (result.skipReason) {
lines.push(`- Skip reason: ${result.skipReason}`)
}
if (result.error) {
lines.push(`- Error: ${result.error}`)
}
if (result.artifactDir) {
lines.push(`- Artifacts: ${result.artifactDir}`)
}
lines.push('')
}
return lines.join('\n')
}

View File

@ -0,0 +1,162 @@
import { describe, expect, test } from 'bun:test'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { lanesForMode } from './modes'
import { renderMarkdownReport } from './reporter'
import { runQualityGate, runQualityGateLanes } from './runner'
import type { LaneDefinition, QualityGateReport } from './types'
describe('quality gate modes', () => {
test('pr mode includes existing path-aware PR checks', () => {
const lanes = lanesForMode('pr').map((lane) => lane.id)
expect(lanes).toContain('impact-report')
expect(lanes).toContain('pr-checks')
expect(lanes.some((lane) => lane.startsWith('baseline:'))).toBe(false)
})
test('baseline mode includes live baseline cases but not native checks', () => {
const lanes = lanesForMode('baseline').map((lane) => lane.id)
expect(lanes).toContain('baseline-catalog')
expect(lanes).toContain('baseline:failing-unit:current-runtime')
expect(lanes).toContain('baseline:multi-file-api:current-runtime')
expect(lanes).toContain('desktop-smoke:agent-browser-chat:current-runtime')
expect(lanes).not.toContain('native-checks')
})
test('release mode composes PR, baseline, and native lanes', () => {
const lanes = lanesForMode('release').map((lane) => lane.id)
expect(lanes).toContain('pr-checks')
expect(lanes).toContain('baseline:failing-unit:current-runtime')
expect(lanes).toContain('desktop-smoke:agent-browser-chat:current-runtime')
expect(lanes).toContain('native-checks')
})
test('baseline mode expands cases across explicit provider/model targets', () => {
const lanes = lanesForMode('baseline', [
{ providerId: 'provider-a', modelId: 'model-a', label: 'provider-a-model-a' },
{ providerId: 'provider-b', modelId: 'model-b', label: 'provider-b-model-b' },
]).map((lane) => lane.id)
expect(lanes).toContain('baseline:failing-unit:provider-a-model-a')
expect(lanes).toContain('baseline:failing-unit:provider-b-model-b')
expect(lanes).toContain('baseline:multi-file-api:provider-a-model-a')
expect(lanes).toContain('baseline:multi-file-api:provider-b-model-b')
expect(lanes).toContain('desktop-smoke:agent-browser-chat:provider-a-model-a')
expect(lanes).toContain('desktop-smoke:agent-browser-chat:provider-b-model-b')
})
})
describe('runQualityGate', () => {
test('writes dry-run reports without executing expensive commands', async () => {
const artifactsDir = mkdtempSync(join(tmpdir(), 'quality-gate-test-'))
try {
const { report, outputDir } = await runQualityGate({
mode: 'baseline',
dryRun: true,
allowLive: false,
baselineTargets: [],
rootDir: process.cwd(),
artifactsDir,
runId: 'dry-run-test',
})
expect(report.mode).toBe('baseline')
expect(report.summary.failed).toBe(0)
expect(report.summary.skipped).toBeGreaterThan(0)
expect(readFileSync(join(outputDir, 'report.json'), 'utf8')).toContain('"mode": "baseline"')
expect(readFileSync(join(outputDir, 'report.md'), 'utf8')).toContain('# Quality Gate Report')
} finally {
rmSync(artifactsDir, { recursive: true, force: true })
}
})
test('continues through later lanes after a failure so reports show full impact', async () => {
const artifactsDir = mkdtempSync(join(tmpdir(), 'quality-gate-test-'))
const lanes: LaneDefinition[] = [
{
id: 'first-lane',
title: 'First lane',
description: 'Fails intentionally',
kind: 'command',
command: ['false'],
requiredForModes: ['pr'],
},
{
id: 'second-lane',
title: 'Second lane',
description: 'Still runs',
kind: 'command',
command: ['true'],
requiredForModes: ['pr'],
},
]
try {
const { report } = await runQualityGateLanes({
mode: 'pr',
dryRun: false,
allowLive: false,
baselineTargets: [],
rootDir: process.cwd(),
artifactsDir,
runId: 'continue-after-failure-test',
}, lanes, async (lane) => ({
id: lane.id,
title: lane.title,
status: lane.id === 'first-lane' ? 'failed' : 'passed',
command: lane.command,
durationMs: 1,
exitCode: lane.id === 'first-lane' ? 1 : 0,
}))
expect(report.results.map((result) => result.id)).toEqual(['first-lane', 'second-lane'])
expect(report.summary).toEqual({
passed: 1,
failed: 1,
skipped: 0,
})
} finally {
rmSync(artifactsDir, { recursive: true, force: true })
}
})
})
describe('renderMarkdownReport', () => {
test('renders command, skip reason, and summary', () => {
const report: QualityGateReport = {
schemaVersion: 1,
runId: 'example',
mode: 'pr',
dryRun: true,
allowLive: false,
startedAt: '2026-05-02T00:00:00.000Z',
finishedAt: '2026-05-02T00:00:01.000Z',
rootDir: process.cwd(),
git: {
sha: 'abc123',
dirty: true,
},
results: [
{
id: 'impact-report',
title: 'Impact report',
status: 'skipped',
command: ['bun', 'run', 'check:impact'],
durationMs: 1,
skipReason: 'dry run',
},
],
summary: {
passed: 0,
failed: 0,
skipped: 1,
},
}
const markdown = renderMarkdownReport(report)
expect(markdown).toContain('Skipped: 1')
expect(markdown).toContain('`bun run check:impact`')
expect(markdown).toContain('dry run')
})
})

View File

@ -0,0 +1,182 @@
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { baselineCases } from './baseline/cases'
import { executeBaselineCase } from './baseline/execute'
import { executeDesktopSmoke } from './desktop-smoke/execute'
import { lanesForMode } from './modes'
import { writeReport } from './reporter'
import type { LaneDefinition, LaneResult, QualityGateOptions, QualityGateReport } from './types'
type LaneExecutor = (lane: LaneDefinition, options: QualityGateOptions) => Promise<LaneResult>
function nowId() {
return new Date().toISOString().replace(/[:.]/g, '-')
}
async function output(cmd: string[], cwd: string) {
const proc = Bun.spawn(cmd, {
cwd,
stdout: 'pipe',
stderr: 'pipe',
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const code = await proc.exited
if (code !== 0) {
return null
}
return (stdout || stderr).trim()
}
async function gitInfo(rootDir: string) {
const sha = await output(['git', 'rev-parse', '--short', 'HEAD'], rootDir)
const status = await output(['git', 'status', '--short'], rootDir)
return {
sha,
dirty: Boolean(status),
}
}
async function runCommandLane(lane: LaneDefinition, options: QualityGateOptions): Promise<LaneResult> {
const started = Date.now()
const command = lane.command ?? []
if (options.dryRun) {
return {
id: lane.id,
title: lane.title,
status: 'skipped',
command,
durationMs: Date.now() - started,
skipReason: 'dry run',
}
}
const proc = Bun.spawn(command, {
cwd: options.rootDir,
stdout: 'inherit',
stderr: 'inherit',
})
const exitCode = await proc.exited
return {
id: lane.id,
title: lane.title,
status: exitCode === 0 ? 'passed' : 'failed',
command,
durationMs: Date.now() - started,
exitCode,
}
}
async function runBaselineCaseLane(lane: LaneDefinition, options: QualityGateOptions): Promise<LaneResult> {
const started = Date.now()
if (!options.allowLive) {
return {
id: lane.id,
title: lane.title,
status: 'skipped',
durationMs: Date.now() - started,
skipReason: 'live baseline cases require --allow-live',
}
}
const caseId = lane.baselineCaseId ?? lane.id.replace(/^baseline:/, '').split(':')[0]
const testCase = baselineCases.find((candidate) => candidate.id === caseId)
if (!testCase) {
return {
id: lane.id,
title: lane.title,
status: 'failed',
durationMs: Date.now() - started,
error: `Unknown baseline case: ${caseId}`,
}
}
const artifactRoot = options.runOutputDir ?? join(options.rootDir, 'artifacts', 'quality-runs', options.runId ?? 'current')
return executeBaselineCase(
testCase,
options.rootDir,
join(artifactRoot, 'cases', lane.id.replace(/[^a-zA-Z0-9._-]+/g, '-')),
lane.baselineTarget,
)
}
async function runLane(lane: LaneDefinition, options: QualityGateOptions): Promise<LaneResult> {
if (lane.kind === 'baseline-case') {
return runBaselineCaseLane(lane, options)
}
if (lane.kind === 'desktop-smoke') {
const started = Date.now()
if (!options.allowLive) {
return {
id: lane.id,
title: lane.title,
status: 'skipped',
durationMs: Date.now() - started,
skipReason: 'desktop agent-browser smoke requires --allow-live',
}
}
const artifactRoot = options.runOutputDir ?? join(options.rootDir, 'artifacts', 'quality-runs', options.runId ?? 'current')
return executeDesktopSmoke(
options.rootDir,
join(artifactRoot, 'cases', lane.id.replace(/[^a-zA-Z0-9._-]+/g, '-')),
lane.id,
lane.title,
lane.baselineTarget,
)
}
return runCommandLane(lane, options)
}
function summarize(results: LaneResult[]) {
return {
passed: results.filter((result) => result.status === 'passed').length,
failed: results.filter((result) => result.status === 'failed').length,
skipped: results.filter((result) => result.status === 'skipped').length,
}
}
export async function runQualityGate(options: QualityGateOptions) {
return runQualityGateLanes(options, lanesForMode(options.mode, options.baselineTargets))
}
export async function runQualityGateLanes(
options: QualityGateOptions,
lanes: LaneDefinition[],
executeLane: LaneExecutor = runLane,
) {
const runId = options.runId ?? nowId()
const startedAt = new Date().toISOString()
const artifactsRoot = options.artifactsDir ?? join(options.rootDir, 'artifacts', 'quality-runs')
const outputDir = join(artifactsRoot, runId)
mkdirSync(outputDir, { recursive: true })
const runOptions = { ...options, runId, runOutputDir: outputDir }
const results: LaneResult[] = []
for (const lane of lanes) {
const result = await executeLane(lane, runOptions)
results.push(result)
}
const report: QualityGateReport = {
schemaVersion: 1,
runId,
mode: options.mode,
dryRun: options.dryRun,
allowLive: options.allowLive,
startedAt,
finishedAt: new Date().toISOString(),
rootDir: options.rootDir,
git: await gitInfo(options.rootDir),
results,
summary: summarize(results),
}
writeReport(report, outputDir)
return { report, outputDir }
}

View File

@ -0,0 +1,85 @@
export type QualityGateMode = 'pr' | 'baseline' | 'release'
export type LaneKind = 'command' | 'baseline-case' | 'desktop-smoke'
export type LaneDefinition = {
id: string
title: string
description: string
kind: LaneKind
command?: string[]
baselineCaseId?: string
baselineTarget?: BaselineTarget
requiredForModes: QualityGateMode[]
live?: boolean
}
export type BaselineCase = {
id: string
title: string
description: string
fixture: string
prompt: string
mode: 'ui' | 'websocket'
requiredCapabilities: Array<'model' | 'file-edit' | 'shell' | 'permission' | 'browser'>
timeoutMs: number
verify: {
commands: string[][]
requiredFiles?: string[]
expectedFiles?: string[]
forbiddenFiles?: string[]
transcriptAssertions?: string[]
}
}
export type BaselineTarget = {
providerId: string | null
modelId: string
label: string
}
export type LaneStatus = 'passed' | 'failed' | 'skipped'
export type LaneResult = {
id: string
title: string
status: LaneStatus
command?: string[]
durationMs: number
exitCode?: number
skipReason?: string
error?: string
artifactDir?: string
}
export type QualityGateOptions = {
mode: QualityGateMode
dryRun: boolean
allowLive: boolean
baselineTargets: BaselineTarget[]
rootDir: string
artifactsDir?: string
runOutputDir?: string
runId?: string
}
export type QualityGateReport = {
schemaVersion: 1
runId: string
mode: QualityGateMode
dryRun: boolean
allowLive: boolean
startedAt: string
finishedAt: string
rootDir: string
git: {
sha: string | null
dirty: boolean
}
results: LaneResult[]
summary: {
passed: number
failed: number
skipped: number
}
}

View File

@ -252,9 +252,11 @@ describe('ConversationService', () => {
it('should reconstruct usage and metadata from a persisted transcript', async () => {
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
const previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-transcript-'))
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-workdir-'))
process.env.CLAUDE_CONFIG_DIR = tmpConfigDir
process.env.ANTHROPIC_API_KEY = 'test-key'
try {
const svc = new SessionService()
@ -305,6 +307,11 @@ describe('ConversationService', () => {
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
if (previousAnthropicApiKey === undefined) {
delete process.env.ANTHROPIC_API_KEY
} else {
process.env.ANTHROPIC_API_KEY = previousAnthropicApiKey
}
await fs.rm(tmpConfigDir, { recursive: true, force: true })
await fs.rm(workDir, { recursive: true, force: true })
}