diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1cd6ebb..2bc90e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,37 @@
All notable changes to `@nugehs/gate` are documented here.
+## [0.3.0] - 2026-06-14
+
+### Engine
+
+- Findings are no longer capped at 5 per domain in the JSON. That cap was a
+ terminal-display nicety that leaked into the data model and starved editor
+ clients of squiggles on any repo with more than a handful of findings. The
+ `findings` array is now bounded only by a high runaway guard (`MAX_FINDINGS`),
+ and the summary counts already carry the true totals.
+- Editor clients can pass `--strict` per run (e.g. from a `gate.strict` setting).
+
+### VS Code / Cursor extension (`clients/vscode` 0.3.0)
+
+The extension grows from a read-only verdict mirror into a full editor surface:
+
+- **AI-native gating** — an `@gate` chat participant (`@gate can this ship?`,
+ `@gate /why`), a `gate_check` Language Model tool agent mode can call before it
+ declares a change done, and an MCP server provider that registers gate's own
+ MCP server with the editor. The assistant writing the code is checked by the
+ same gate CI uses.
+- **Verdict cockpit** — a webview in gate's own Activity Bar container: the
+ unified verdict as an interactive board (jump to a finding, mute it, re-check).
+- **Interactive findings** — Quick Fixes (mute a finding, open the tool's docs),
+ hovers with full detail, and a CodeLens above any line that carries a finding.
+- **Faster, safer feedback loop** — saves are debounced and an in-flight run is
+ superseded (its child killed) instead of piling up overlapping full-repo gates.
+- **Multi-root workspaces** — every folder is checked; the status bar shows the
+ worst verdict, the tree gains a per-folder layer.
+- **More settings** — `gate.strict`, `gate.codeLens`, `gate.debounceMs`; a
+ getting-started walkthrough; and an "install the engine" flow.
+
## [0.2.0] - 2026-06-14
- Findings now carry `file` and `line` where the underlying tool provides a
diff --git a/README.md b/README.md
index 766fe7f..5d2c623 100644
--- a/README.md
+++ b/README.md
@@ -145,13 +145,26 @@ Tools:
Registry manifest: [`server.json`](server.json) (`io.github.nugehs/gate`).
+## Editor extension
+
+The same normalized verdict drives a **VS Code / Cursor extension**
+([`clients/vscode`](clients/vscode)) — the gates, shifted left from CI into the
+editor:
+
+- a **verdict cockpit** and a checks tree in gate's own Activity Bar container;
+- **inline diagnostics**, hovers, Quick Fixes (mute / open docs) and CodeLens on
+ located findings;
+- **AI-native gating** — an `@gate` chat participant, a `gate_check` tool agent
+ mode can call before it says "done", and an MCP server provider — so the
+ assistant writing the code is checked by the same gate as CI;
+- debounced run-on-save with in-flight cancellation, and multi-root support.
+
## Roadmap
-gate is the shared spine. The same normalized verdict already drives the CLI,
-the `--ci` gate, and the MCP server above. Next clients on the same JSON:
+gate is the shared spine. The same JSON already drives the CLI, the `--ci` gate,
+the MCP server, and the editor extension above. Next client on the same JSON:
- **Web cockpit** — a repo/PR verdict board over the JSON, unifying the four `*-web` sites.
-- **Editor extension** — shift the gates left from CI into the editor as inline findings.
## License
diff --git a/clients/vscode/README.md b/clients/vscode/README.md
index 0888f53..81f6693 100644
--- a/clients/vscode/README.md
+++ b/clients/vscode/README.md
@@ -1,26 +1,59 @@
# gate — VS Code & Cursor extension
-One ship/no-ship verdict in your editor. The extension runs
-[`@nugehs/gate`](https://github.com/nugehs/gate) against your workspace and shows
-the unified verdict from **aiglare**, **bouncer**, **tieline** and **repoctx**.
+**One ship/no-ship verdict in your editor — and in your AI assistant.**
+
+The extension runs [`@nugehs/gate`](https://github.com/nugehs/gate) against your
+workspace and turns the unified verdict from **aiglare** (AI governance),
+**bouncer** (compliance), **tieline** (contract drift) and **repoctx** (merge
+readiness) into a live editor surface.
> Cursor, VSCodium and Windsurf are VS Code forks — this is the same extension
-> for all of them. Install it from [Open VSX](https://open-vsx.org) or from a
-> packaged `.vsix`.
+> for all of them. Install it from [Open VSX](https://open-vsx.org) or a packaged
+> `.vsix`.
## What you get
-- **Status bar** — `✓ / ⚠ / ✗ gate: VERDICT`. Click to re-check.
-- **`gate` panel** (Explorer) — the four checks with their status and summary,
- expandable to findings (click a finding to jump to its line).
+### The verdict, everywhere
+
+- **Status bar** — `✓ / ⚠ / ✗ gate: VERDICT`. Click to open the cockpit.
+- **Verdict cockpit** — a board in gate's own Activity Bar container: the overall
+ verdict, a card per domain, the blocking reasons, and every located finding.
+ Click a finding to jump to it, mute it, or re-check — all without leaving it.
+- **Checks tree** — the four domains, expandable to findings (multi-root
+ workspaces get a folder layer on top).
- **Inline diagnostics** — squiggles on the exact line for findings that carry a
- location: **aiglare** red surfaces and **tieline** drift. bouncer (a missing
- control is an *absence*, no line) and repoctx (repo-level) stay in the panel.
-- **Command** — `gate: Check Workspace`.
-- **Run on save** — re-checks when you save (toggle with `gate.runOnSave`).
+ location (aiglare red surfaces, tieline drift), with a clickable rule link.
+
+### Interactive findings
+
+- **Quick Fixes** on any finding — **Mute** it (per-workspace, reversible) or
+ **open the tool's docs**.
+- **Hovers** with the full finding detail and a one-click mute.
+- **CodeLens** above any line that carries a finding (toggle with `gate.codeLens`).
+
+### AI-native gating
+
+The assistant writing your code is checked by the same gate your CI uses:
-> Squiggles need the engine to emit `file:line` (gate ≥ 0.2.0). Against an older
-> published gate the panel still works; the diagnostics simply stay empty.
+- **`@gate` chat participant** — ask `@gate can this ship?`, or `@gate /why` for
+ the blocking reasons in plain language.
+- **Agent tool** — in agent mode the model can call the **`gate_check`** tool
+ itself before it claims a change is ready; a `fail` verdict tells it not to ship.
+- **MCP server** — gate registers its own MCP server with the editor, so any
+ MCP-aware agent gets the unified `gate_check` tool automatically.
+
+### A feedback loop that keeps up
+
+- **Run on save**, debounced — a save-storm collapses into one run.
+- **In-flight runs are superseded** (the child process is killed) instead of
+ piling up overlapping full-repo gates.
+- **Multi-root** — every workspace folder is checked; the status bar reports the
+ worst verdict across them.
+
+> Squiggles, hovers and CodeLens need the engine to emit `file:line` and the full
+> finding set (gate ≥ 0.3.0). Against an older engine the verdict still works; the
+> located surfaces simply thin out. bouncer (a missing control is an *absence*,
+> no line) and repoctx (repo-level) stay in the cockpit and tree by nature.
## Requirements
@@ -31,15 +64,27 @@ The `gate` engine must be resolvable. The extension looks, in order, for:
3. `gate` on `PATH`
4. `npx @nugehs/gate`
+No engine installed? Run **gate: Install the gate engine** (or the button in the
+cockpit / walkthrough).
+
## Settings
| Setting | Default | Meaning |
| --- | --- | --- |
| `gate.path` | `""` | Explicit path to gate (bin or `src/cli.js`). |
-| `gate.runOnSave` | `true` | Re-run gate on file save. |
+| `gate.runOnSave` | `true` | Re-run gate on file save (debounced). |
+| `gate.strict` | `false` | Treat WARN/UNKNOWN as blocking too (`--strict`). |
+| `gate.codeLens` | `true` | Show a CodeLens above lines with a finding. |
+| `gate.debounceMs` | `500` | Delay after the last save before re-running. |
| `gate.only` | `[]` | Run only these checks. |
| `gate.skip` | `[]` | Skip these checks. |
+## Commands
+
+`gate: Check Workspace`, `gate: Refresh`, `gate: Open Verdict Cockpit`,
+`gate: Show Output Log`, `gate: Clear Muted Findings`,
+`gate: Install the gate engine`.
+
## Develop
```
diff --git a/clients/vscode/media/gate.svg b/clients/vscode/media/gate.svg
new file mode 100644
index 0000000..263533a
--- /dev/null
+++ b/clients/vscode/media/gate.svg
@@ -0,0 +1,4 @@
+
diff --git a/clients/vscode/media/walkthrough/ai.md b/clients/vscode/media/walkthrough/ai.md
new file mode 100644
index 0000000..89f8435
--- /dev/null
+++ b/clients/vscode/media/walkthrough/ai.md
@@ -0,0 +1,14 @@
+## Ask the AI
+
+gate is wired into the editor's AI surfaces, so the assistant writing your code is
+checked by the same gate your CI uses.
+
+- **`@gate` chat participant** — in Copilot Chat, type `@gate can this ship?` or
+ `@gate /why` to get the verdict and the blocking reasons in plain language.
+- **Agent tool** — in agent mode, the model can call the **`#gate`** tool itself
+ before it claims a change is ready. A `fail` verdict tells it not to ship.
+- **MCP server** — gate registers its MCP server with the editor, so any
+ MCP-aware agent gets the unified `gate_check` tool automatically.
+
+This is the whole point of the nugehs toolchain: governance that travels with the
+code, from the editor to CI.
diff --git a/clients/vscode/media/walkthrough/install.md b/clients/vscode/media/walkthrough/install.md
new file mode 100644
index 0000000..b1ad33e
--- /dev/null
+++ b/clients/vscode/media/walkthrough/install.md
@@ -0,0 +1,19 @@
+## Install the engine
+
+The extension is a thin client over the **`@nugehs/gate`** CLI. Install it once:
+
+```
+npm i -g @nugehs/gate # global
+# or, per project:
+npm i -D @nugehs/gate
+```
+
+The extension resolves the engine in this order:
+
+1. the `gate.path` setting
+2. `node_modules/.bin/gate` in your workspace
+3. `gate` on your `PATH`
+4. `npx @nugehs/gate` (no install needed, just slower on first run)
+
+You don't need all four underlying tools (aiglare, bouncer, tieline, repoctx) —
+any that aren't configured are reported as **skipped**, never a failure.
diff --git a/clients/vscode/media/walkthrough/run.md b/clients/vscode/media/walkthrough/run.md
new file mode 100644
index 0000000..e41f107
--- /dev/null
+++ b/clients/vscode/media/walkthrough/run.md
@@ -0,0 +1,18 @@
+## Run your first check
+
+Open the **gate** icon in the Activity Bar, or run **gate: Check Workspace** from
+the Command Palette.
+
+You get one normalized verdict:
+
+| Status | Meaning |
+| --- | --- |
+| ✓ pass | every check that ran is clean |
+| ⚠ warn | something worth a look — not blocking |
+| ✗ fail | a blocking problem; do not ship |
+
+Findings that carry a line (aiglare red surfaces, tieline drift) show up as inline
+squiggles you can click to jump to. The **Verdict** cockpit summarizes all four
+domains; the **Checks** tree lets you drill into individual findings.
+
+Re-checks run automatically on save (toggle with `gate.runOnSave`).
diff --git a/clients/vscode/package-lock.json b/clients/vscode/package-lock.json
index d6374e4..6373cd7 100644
--- a/clients/vscode/package-lock.json
+++ b/clients/vscode/package-lock.json
@@ -1,20 +1,20 @@
{
"name": "gate",
- "version": "0.2.0",
+ "version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gate",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^20",
- "@types/vscode": "^1.85.0",
+ "@types/vscode": "^1.101.0",
"typescript": "^5.4.0"
},
"engines": {
- "vscode": "^1.85.0"
+ "vscode": "^1.101.0"
}
},
"node_modules/@types/node": {
diff --git a/clients/vscode/package.json b/clients/vscode/package.json
index 0545e6a..68851b0 100644
--- a/clients/vscode/package.json
+++ b/clients/vscode/package.json
@@ -1,30 +1,46 @@
{
"name": "gate",
- "displayName": "gate — unified verdict",
- "description": "One ship/no-ship verdict in your editor: aiglare, bouncer, tieline & repoctx via @nugehs/gate. Works in VS Code and Cursor.",
- "version": "0.2.0",
+ "displayName": "gate — unified ship/no-ship verdict",
+ "description": "One ship/no-ship verdict in your editor: AI governance, compliance, contract drift & merge readiness via @nugehs/gate. Inline diagnostics, a verdict cockpit, an @gate chat participant, and a Copilot agent tool. VS Code & Cursor.",
+ "version": "0.3.0",
"publisher": "nugehs",
"license": "MIT",
"icon": "icon.png",
+ "galleryBanner": {
+ "color": "#0B1020",
+ "theme": "dark"
+ },
"engines": {
- "vscode": "^1.85.0"
+ "vscode": "^1.101.0"
},
+ "extensionKind": [
+ "workspace"
+ ],
"categories": [
"Linters",
+ "Testing",
+ "AI",
"Other"
],
"keywords": [
"gate",
"governance",
"compliance",
+ "ai",
+ "mcp",
"ci",
"cursor"
],
+ "qna": "marketplace",
"repository": {
"type": "git",
"url": "git+https://github.com/nugehs/gate.git",
"directory": "clients/vscode"
},
+ "bugs": {
+ "url": "https://github.com/nugehs/gate/issues"
+ },
+ "homepage": "https://github.com/nugehs/gate#readme",
"main": "./out/extension.js",
"activationEvents": [
"onStartupFinished"
@@ -40,13 +56,48 @@
"command": "gate.refresh",
"title": "gate: Refresh",
"icon": "$(refresh)"
+ },
+ {
+ "command": "gate.showOutput",
+ "title": "gate: Show Output Log",
+ "icon": "$(output)"
+ },
+ {
+ "command": "gate.focusCockpit",
+ "title": "gate: Open Verdict Cockpit"
+ },
+ {
+ "command": "gate.muteFinding",
+ "title": "gate: Mute This Finding"
+ },
+ {
+ "command": "gate.clearMutes",
+ "title": "gate: Clear Muted Findings"
+ },
+ {
+ "command": "gate.installEngine",
+ "title": "gate: Install the gate engine (npm i -g @nugehs/gate)"
}
],
+ "viewsContainers": {
+ "activitybar": [
+ {
+ "id": "gate",
+ "title": "gate",
+ "icon": "media/gate.svg"
+ }
+ ]
+ },
"views": {
- "explorer": [
+ "gate": [
+ {
+ "id": "gate.cockpit",
+ "name": "Verdict",
+ "type": "webview"
+ },
{
"id": "gate.verdict",
- "name": "gate"
+ "name": "Checks"
}
]
},
@@ -56,6 +107,17 @@
"command": "gate.refresh",
"when": "view == gate.verdict",
"group": "navigation"
+ },
+ {
+ "command": "gate.showOutput",
+ "when": "view == gate.verdict",
+ "group": "navigation"
+ }
+ ],
+ "commandPalette": [
+ {
+ "command": "gate.muteFinding",
+ "when": "false"
}
]
},
@@ -70,7 +132,23 @@
"gate.runOnSave": {
"type": "boolean",
"default": true,
- "description": "Re-run gate when a file is saved."
+ "description": "Re-run gate when a file is saved (debounced)."
+ },
+ "gate.strict": {
+ "type": "boolean",
+ "default": false,
+ "description": "Treat WARN/UNKNOWN as blocking too (passes --strict to the engine)."
+ },
+ "gate.codeLens": {
+ "type": "boolean",
+ "default": true,
+ "description": "Show a CodeLens above lines that carry a gate finding."
+ },
+ "gate.debounceMs": {
+ "type": "number",
+ "default": 500,
+ "minimum": 0,
+ "description": "How long to wait after the last save before re-running gate."
},
"gate.only": {
"type": "array",
@@ -91,7 +169,97 @@
"description": "Skip these checks."
}
}
- }
+ },
+ "walkthroughs": [
+ {
+ "id": "gate.gettingStarted",
+ "title": "Get started with gate",
+ "description": "One ship/no-ship verdict from aiglare, bouncer, tieline & repoctx — right in your editor.",
+ "steps": [
+ {
+ "id": "gate.install",
+ "title": "Install the engine",
+ "description": "gate runs the @nugehs/gate CLI under the hood. Install it once, globally or in your project.\n[Install gate](command:gate.installEngine)",
+ "media": {
+ "markdown": "media/walkthrough/install.md"
+ }
+ },
+ {
+ "id": "gate.run",
+ "title": "Run your first check",
+ "description": "Run the unified gate over your workspace and read the verdict in the cockpit.\n[Check workspace](command:gate.check)",
+ "media": {
+ "markdown": "media/walkthrough/run.md"
+ }
+ },
+ {
+ "id": "gate.ai",
+ "title": "Ask the AI",
+ "description": "Type @gate in Copilot Chat to ask \"can this ship?\" — or let agent mode call the gate tool before it says a change is done.",
+ "media": {
+ "markdown": "media/walkthrough/ai.md"
+ }
+ }
+ ]
+ }
+ ],
+ "languageModelTools": [
+ {
+ "name": "gate_check",
+ "tags": ["governance", "ci", "nugehs", "ship"],
+ "toolReferenceName": "gate",
+ "displayName": "Gate: can this ship?",
+ "modelDescription": "Run the nugehs gate against the current workspace and return one normalized ship/no-ship verdict. It merges four deterministic checks — aiglare (AI/LLM governance guardrails), bouncer (compliance controls), tieline (frontend/backend contract drift) and repoctx (merge readiness) — into a single verdict (pass | warn | fail) with the blocking reasons. Call this before telling the user a change is ready to merge, ship, or deploy; a 'fail' verdict means do not ship.",
+ "canBeReferencedInPrompt": true,
+ "icon": "$(shield)",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "only": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["aiglare", "bouncer", "tieline", "repoctx"]
+ },
+ "description": "Run only these checks."
+ },
+ "skip": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["aiglare", "bouncer", "tieline", "repoctx"]
+ },
+ "description": "Skip these checks."
+ },
+ "strict": {
+ "type": "boolean",
+ "description": "Treat warnings and unknowns as blocking too."
+ }
+ }
+ }
+ }
+ ],
+ "chatParticipants": [
+ {
+ "id": "nugehs.gate",
+ "name": "gate",
+ "fullName": "gate",
+ "description": "Can this ship? The unified nugehs verdict.",
+ "isSticky": true,
+ "commands": [
+ {
+ "name": "why",
+ "description": "Explain the current blocking reasons in plain language."
+ }
+ ]
+ }
+ ],
+ "mcpServerDefinitionProviders": [
+ {
+ "id": "nugehs-gate",
+ "label": "gate (nugehs)"
+ }
+ ]
},
"scripts": {
"vscode:prepublish": "npm run compile",
@@ -104,7 +272,7 @@
},
"devDependencies": {
"@types/node": "^20",
- "@types/vscode": "^1.85.0",
+ "@types/vscode": "^1.101.0",
"typescript": "^5.4.0"
}
}
diff --git a/clients/vscode/src/ai.ts b/clients/vscode/src/ai.ts
new file mode 100644
index 0000000..1606ae6
--- /dev/null
+++ b/clients/vscode/src/ai.ts
@@ -0,0 +1,209 @@
+// Tier 0 — make the AI assistant itself gated. Three editor AI surfaces, all
+// backed by the same unified verdict:
+// • Language Model Tool — agent mode can call `gate_check` before it says "done"
+// • Chat participant — `@gate can this ship?` / `@gate /why`
+// • MCP server provider — registers gate's own MCP server with the editor
+//
+// Every entry point is feature-detected, so the extension still loads on an
+// editor/fork that doesn't implement a given API (e.g. older Cursor builds).
+
+import * as vscode from 'vscode';
+import * as path from 'node:path';
+import { existsSync } from 'node:fs';
+import { overallStatus, type GateRunner, type FolderResult } from './gate';
+
+interface GateToolInput {
+ only?: string[];
+ skip?: string[];
+ strict?: boolean;
+}
+
+export function registerAi(
+ context: vscode.ExtensionContext,
+ runner: GateRunner,
+ workspaceRoot: () => string | undefined,
+ configuredPath: () => string | undefined
+): void {
+ registerTool(context, runner);
+ registerParticipant(context, runner);
+ registerMcp(context, workspaceRoot, configuredPath);
+}
+
+// ---- Agent tool ----
+
+function registerTool(context: vscode.ExtensionContext, runner: GateRunner): void {
+ if (typeof vscode.lm?.registerTool !== 'function') return;
+
+ const tool: vscode.LanguageModelTool = {
+ async invoke(options, token) {
+ const input = options.input ?? {};
+ const ac = new AbortController();
+ token.onCancellationRequested(() => ac.abort());
+ const results = await runner.run({ only: input.only, skip: input.skip, strict: input.strict, signal: ac.signal });
+ return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(formatForModel(results))]);
+ },
+ prepareInvocation() {
+ return { invocationMessage: 'Running gate…' };
+ },
+ };
+
+ context.subscriptions.push(vscode.lm.registerTool('gate_check', tool));
+}
+
+// ---- Chat participant ----
+
+function registerParticipant(context: vscode.ExtensionContext, runner: GateRunner): void {
+ if (typeof vscode.chat?.createChatParticipant !== 'function') return;
+
+ const handler: vscode.ChatRequestHandler = async (request, _ctxt, stream, token) => {
+ stream.progress('Running gate…');
+ const ac = new AbortController();
+ token.onCancellationRequested(() => ac.abort());
+
+ let results: FolderResult[];
+ try {
+ results = await runner.run({ signal: ac.signal });
+ } catch (err) {
+ stream.markdown(`Could not run gate: ${err instanceof Error ? err.message : String(err)}`);
+ return {};
+ }
+
+ renderChat(stream, results, request.command);
+ return {};
+ };
+
+ const participant = vscode.chat.createChatParticipant('nugehs.gate', handler);
+ participant.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'gate.svg');
+ context.subscriptions.push(participant);
+}
+
+function renderChat(stream: vscode.ChatResponseStream, results: FolderResult[], command?: string): void {
+ const overall = overallStatus(results);
+ stream.markdown(`### ${mark(overall)} gate: **${overall.toUpperCase()}**\n\n`);
+
+ for (const r of results) {
+ if (results.length > 1) stream.markdown(`#### ${r.name}\n\n`);
+ if (r.error) {
+ stream.markdown(`- could not run: ${r.error}\n`);
+ continue;
+ }
+ const v = r.verdict;
+ if (!v) continue;
+ for (const d of v.domains) {
+ stream.markdown(`- ${mark(d.status)} **${d.label}** — ${d.status}${d.summary ? ` · ${d.summary}` : ''}\n`);
+ }
+ const reasons = v.gate?.reasons ?? [];
+ if (reasons.length) {
+ stream.markdown(`\n**Blocking reasons:**\n`);
+ for (const x of reasons) stream.markdown(`- ${x}\n`);
+ }
+ }
+
+ stream.markdown(`\n${command === 'why' ? whyGuidance(overall) : verdictLine(overall)}\n`);
+ stream.button({ command: 'gate.focusCockpit', title: 'Open verdict cockpit' });
+}
+
+// ---- MCP server provider ----
+
+function registerMcp(
+ context: vscode.ExtensionContext,
+ workspaceRoot: () => string | undefined,
+ configuredPath: () => string | undefined
+): void {
+ if (typeof vscode.lm?.registerMcpServerDefinitionProvider !== 'function') return;
+
+ const provider: vscode.McpServerDefinitionProvider = {
+ provideMcpServerDefinitions() {
+ const { command, args } = mcpCommand(workspaceRoot(), configuredPath());
+ return [new vscode.McpStdioServerDefinition('gate', command, args)];
+ },
+ };
+
+ try {
+ context.subscriptions.push(vscode.lm.registerMcpServerDefinitionProvider('nugehs-gate', provider));
+ } catch {
+ // API present in types but not implemented on this host — skip silently.
+ }
+}
+
+function mcpCommand(root: string | undefined, configured: string | undefined): { command: string; args: string[] } {
+ if (configured) {
+ return configured.endsWith('.js')
+ ? { command: process.execPath, args: [configured, 'mcp'] }
+ : { command: configured, args: ['mcp'] };
+ }
+ if (root) {
+ const local = path.join(root, 'node_modules', '.bin', 'gate');
+ if (existsSync(local)) return { command: local, args: ['mcp'] };
+ }
+ return { command: 'npx', args: ['--yes', '@nugehs/gate', 'mcp'] };
+}
+
+// ---- Shared formatting ----
+
+function mark(status: string): string {
+ if (status === 'fail' || status === 'error') return '✗';
+ if (status === 'warn' || status === 'unknown') return '⚠';
+ if (status === 'pass') return '✓';
+ return '·';
+}
+
+function verdictLine(overall: string): string {
+ switch (overall) {
+ case 'fail':
+ return 'A blocking problem was found — resolve the reasons above before shipping.';
+ case 'warn':
+ return 'Shippable, but with warnings worth a look.';
+ case 'pass':
+ return 'Clean — every check that ran passed.';
+ default:
+ return 'No checks ran for this workspace.';
+ }
+}
+
+function whyGuidance(overall: string): string {
+ switch (overall) {
+ case 'fail':
+ return 'Each blocking reason names the tool and the failing check. Fix those (an aiglare red surface on a side-effectful sink, a tieline drift, a missing bouncer control, or a repoctx merge gate) and re-run gate.';
+ case 'warn':
+ return 'Warnings do not block by default. Review them, or enable `gate.strict` to treat them as blocking.';
+ case 'pass':
+ return 'Nothing is blocking. Every check that applied to this repo passed.';
+ default:
+ return 'No domain actually ran — every check was skipped or deselected. Configure at least one tool (e.g. `bouncer init`, `tieline init`).';
+ }
+}
+
+/** Plain-text verdict the model can act on — explicit "do not ship" on a fail. */
+function formatForModel(results: FolderResult[]): string {
+ const overall = overallStatus(results);
+ const lines: string[] = [`gate verdict: ${overall.toUpperCase()}`];
+
+ for (const r of results) {
+ const head = results.length > 1 ? `[${r.name}] ` : '';
+ if (r.error) {
+ lines.push(`${head}error — ${r.error}`);
+ continue;
+ }
+ const v = r.verdict;
+ if (!v) {
+ lines.push(`${head}not run`);
+ continue;
+ }
+ if (v.gate?.nothingChecked) lines.push(`${head}NO CHECKS RAN — ${(v.gate.reasons ?? []).join('; ')}`);
+ for (const d of v.domains) lines.push(`${head}${d.label}: ${d.status}${d.summary ? ' — ' + d.summary : ''}`);
+ for (const reason of v.gate?.reasons ?? []) lines.push(`${head}BLOCKING: ${reason}`);
+ }
+
+ lines.push('');
+ lines.push(
+ overall === 'fail'
+ ? 'Do not ship: at least one blocking problem was found. Resolve the BLOCKING items above before merging.'
+ : overall === 'warn'
+ ? 'Shippable, but with warnings worth a look.'
+ : overall === 'pass'
+ ? 'Clean: every check that ran passed.'
+ : 'No checks ran for this workspace.'
+ );
+ return lines.join('\n');
+}
diff --git a/clients/vscode/src/cockpit.ts b/clients/vscode/src/cockpit.ts
new file mode 100644
index 0000000..e8620d1
--- /dev/null
+++ b/clients/vscode/src/cockpit.ts
@@ -0,0 +1,255 @@
+// Tier 3 — the verdict "cockpit": a webview view in gate's own Activity Bar
+// container. It renders the same unified verdict the CLI prints, but as an
+// interactive board — click a finding to jump to it, mute it, or re-check.
+//
+// It reads the latest run through GateView and never runs the engine directly;
+// every action posts a message that the extension turns into a command.
+
+import * as vscode from 'vscode';
+import * as path from 'node:path';
+import { randomBytes } from 'node:crypto';
+import { overallStatus, findingKey, type GateView, type FolderResult, type DomainResult, type Finding } from './gate';
+
+type Msg =
+ | { type: 'check' }
+ | { type: 'install' }
+ | { type: 'clearMutes' }
+ | { type: 'mute'; key: string }
+ | { type: 'open'; file: string; line?: number };
+
+export class CockpitProvider implements vscode.WebviewViewProvider {
+ static readonly viewId = 'gate.cockpit';
+ private webviewView?: vscode.WebviewView;
+ private busy = false;
+
+ constructor(private readonly view: GateView, private readonly extensionUri: vscode.Uri) {}
+
+ resolveWebviewView(webviewView: vscode.WebviewView): void {
+ this.webviewView = webviewView;
+ webviewView.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] };
+ webviewView.webview.onDidReceiveMessage((m: Msg) => this.onMessage(m));
+ this.render();
+ }
+
+ setBusy(busy: boolean): void {
+ this.busy = busy;
+ this.render();
+ }
+
+ refresh(): void {
+ this.render();
+ }
+
+ private onMessage(m: Msg): void {
+ switch (m?.type) {
+ case 'check':
+ vscode.commands.executeCommand('gate.check');
+ break;
+ case 'install':
+ vscode.commands.executeCommand('gate.installEngine');
+ break;
+ case 'clearMutes':
+ vscode.commands.executeCommand('gate.clearMutes');
+ break;
+ case 'mute':
+ if (m.key) vscode.commands.executeCommand('gate.muteFinding', m.key);
+ break;
+ case 'open':
+ if (typeof m.file === 'string') {
+ const l = typeof m.line === 'number' ? Math.max(0, m.line - 1) : 0;
+ vscode.commands.executeCommand('vscode.open', vscode.Uri.file(m.file), {
+ selection: new vscode.Range(l, 0, l, 0),
+ });
+ }
+ break;
+ }
+ }
+
+ private render(): void {
+ if (!this.webviewView) return;
+ const w = this.webviewView.webview;
+ w.html = renderHtml(w, this.view, this.busy);
+ }
+}
+
+// ---- HTML rendering (pure-ish string building) ----
+
+const STATUS_CLASS: Record = {
+ pass: 'ok',
+ warn: 'warn',
+ unknown: 'warn',
+ fail: 'bad',
+ error: 'bad',
+ skipped: 'mute',
+};
+
+function esc(s: unknown): string {
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
+}
+
+function resolveAbs(file: string, root: string): string {
+ return path.isAbsolute(file) ? file : path.join(root, file);
+}
+
+function nonceStr(): string {
+ return randomBytes(16).toString('base64').replace(/[^a-zA-Z0-9]/g, '').slice(0, 24);
+}
+
+function findingRow(d: DomainResult, f: Finding, root: string, muted: ReadonlySet): string {
+ const title = String(f.title ?? f.id ?? 'finding');
+ const hasLoc = typeof f.file === 'string' && f.file;
+ const abs = hasLoc ? resolveAbs(String(f.file), root) : '';
+ const line = typeof f.line === 'number' && f.line > 0 ? f.line : 1;
+ const key = hasLoc ? findingKey(d.tool, abs, line, title) : '';
+ if (key && muted.has(key)) return '';
+
+ const loc = hasLoc ? `${path.basename(abs)}:${line}` : '';
+ const sev = f.severity ? `${esc(f.severity)}` : '';
+ const openAttr = hasLoc ? ` data-open="${esc(abs)}" data-line="${line}"` : '';
+ const muteBtn = key ? `` : '';
+ return `