diff --git a/tests/cli/_lib/run.sh b/tests/cli/_lib/run.sh
new file mode 100755
index 0000000..9678796
--- /dev/null
+++ b/tests/cli/_lib/run.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+
+run_capture_stdout() {
+ local expected_status="$1"
+ local output_file="$2"
+ shift 2
+
+ set +e
+ "$@" >"$output_file"
+ local status=$?
+ set -e
+
+ if [[ "$status" -ne "$expected_status" ]]; then
+ printf 'expected exit %s, got %s\n' "$expected_status" "$status" >&2
+ if [[ -s "$output_file" ]]; then
+ printf 'stdout:\n' >&2
+ cat "$output_file" >&2
+ fi
+ return 1
+ fi
+}
+
+run_capture_all() {
+ local expected_status="$1"
+ local output_file="$2"
+ shift 2
+
+ set +e
+ "$@" >"$output_file" 2>&1
+ local status=$?
+ set -e
+
+ if [[ "$status" -ne "$expected_status" ]]; then
+ printf 'expected exit %s, got %s\n' "$expected_status" "$status" >&2
+ if [[ -s "$output_file" ]]; then
+ printf 'output:\n' >&2
+ cat "$output_file" >&2
+ fi
+ return 1
+ fi
+}
+
+assert_jq() {
+ local expression="$1"
+ local input_file="$2"
+ jq -e "$expression" "$input_file" >/dev/null
+}
+
+assert_contains() {
+ local needle="$1"
+ local input_file="$2"
+ grep -Fq "$needle" "$input_file"
+}
diff --git a/tests/cli/lint/catalog/list/Taskfile.yaml b/tests/cli/lint/catalog/list/Taskfile.yaml
new file mode 100644
index 0000000..89464fb
--- /dev/null
+++ b/tests/cli/lint/catalog/list/Taskfile.yaml
@@ -0,0 +1,18 @@
+version: "3"
+
+tasks:
+ test:
+ cmds:
+ - |
+ bash <<'EOF'
+ set -euo pipefail
+ source ../../../_lib/run.sh
+
+ go build -trimpath -ldflags="-s -w" -o bin/core-lint ../../../../../cmd/core-lint
+
+ lang="$(cat fixtures/lang.txt)"
+ output="$(mktemp)"
+ run_capture_all 0 "$output" ./bin/core-lint lint catalog list --lang "$lang"
+ grep -Fq "go-sec-001" "$output"
+ grep -Fq "rule(s)" "$output"
+ EOF
diff --git a/tests/cli/lint/catalog/list/fixtures/lang.txt b/tests/cli/lint/catalog/list/fixtures/lang.txt
new file mode 100644
index 0000000..4023f20
--- /dev/null
+++ b/tests/cli/lint/catalog/list/fixtures/lang.txt
@@ -0,0 +1 @@
+go
diff --git a/tests/cli/lint/catalog/show/Taskfile.yaml b/tests/cli/lint/catalog/show/Taskfile.yaml
new file mode 100644
index 0000000..72e27f1
--- /dev/null
+++ b/tests/cli/lint/catalog/show/Taskfile.yaml
@@ -0,0 +1,18 @@
+version: "3"
+
+tasks:
+ test:
+ cmds:
+ - |
+ bash <<'EOF'
+ set -euo pipefail
+ source ../../../_lib/run.sh
+
+ go build -trimpath -ldflags="-s -w" -o bin/core-lint ../../../../../cmd/core-lint
+
+ rule_id="$(cat fixtures/rule-id.txt)"
+ output="$(mktemp)"
+ run_capture_stdout 0 "$output" ./bin/core-lint lint catalog show "$rule_id"
+ jq -e '.id == "go-sec-001" and .severity == "high" and (.languages | index("go") != null)' "$output" >/dev/null
+ jq -e '.title == "SQL wildcard injection in LIKE clauses"' "$output" >/dev/null
+ EOF
diff --git a/tests/cli/lint/catalog/show/fixtures/rule-id.txt b/tests/cli/lint/catalog/show/fixtures/rule-id.txt
new file mode 100644
index 0000000..0dea602
--- /dev/null
+++ b/tests/cli/lint/catalog/show/fixtures/rule-id.txt
@@ -0,0 +1 @@
+go-sec-001
diff --git a/tests/cli/lint/check/Taskfile.yaml b/tests/cli/lint/check/Taskfile.yaml
new file mode 100644
index 0000000..19e1ffb
--- /dev/null
+++ b/tests/cli/lint/check/Taskfile.yaml
@@ -0,0 +1,17 @@
+version: "3"
+
+tasks:
+ test:
+ cmds:
+ - |
+ bash <<'EOF'
+ set -euo pipefail
+ source ../../_lib/run.sh
+
+ go build -trimpath -ldflags="-s -w" -o bin/core-lint ../../../../cmd/core-lint
+
+ output="$(mktemp)"
+ run_capture_stdout 0 "$output" ./bin/core-lint lint check --format=json fixtures
+ jq -e 'length == 1 and .[0].rule_id == "go-cor-003" and .[0].file == "input.go"' "$output" >/dev/null
+ jq -e '.[0].severity == "medium" and .[0].fix != ""' "$output" >/dev/null
+ EOF
diff --git a/tests/cli/lint/check/fixtures/input.go b/tests/cli/lint/check/fixtures/input.go
new file mode 100644
index 0000000..9c9883d
--- /dev/null
+++ b/tests/cli/lint/check/fixtures/input.go
@@ -0,0 +1,12 @@
+//go:build ignore
+
+package sample
+
+type service struct{}
+
+func (service) Process(string) error { return nil }
+
+func Run() {
+ svc := service{}
+ _ = svc.Process("data")
+}
diff --git a/tests/cli/qa/_harness/main.go b/tests/cli/qa/_harness/main.go
new file mode 100644
index 0000000..e19eb9f
--- /dev/null
+++ b/tests/cli/qa/_harness/main.go
@@ -0,0 +1,11 @@
+package main
+
+import (
+ "forge.lthn.ai/core/cli/pkg/cli"
+ _ "forge.lthn.ai/core/lint/cmd/qa"
+)
+
+func main() {
+ cli.WithAppName("core")
+ cli.Main()
+}
diff --git a/tests/cli/qa/audit/Taskfile.yaml b/tests/cli/qa/audit/Taskfile.yaml
new file mode 100644
index 0000000..bd4c8f6
--- /dev/null
+++ b/tests/cli/qa/audit/Taskfile.yaml
@@ -0,0 +1,20 @@
+version: "3"
+
+tasks:
+ test:
+ cmds:
+ - |
+ bash <<'EOF'
+ set -euo pipefail
+ source ../../_lib/run.sh
+
+ go build -trimpath -ldflags="-s -w" -o bin/core ../_harness
+
+ cd fixtures/project
+ output="$(mktemp)"
+ export PATH="$(pwd)/../bin:$PATH"
+ run_capture_stdout 1 "$output" ../../bin/core qa audit --json
+ jq -e '.results[0].tool == "composer" and .results[0].vulnerabilities == 1' "$output" >/dev/null
+ jq -e '.has_vulnerabilities == true and .vulnerabilities == 1' "$output" >/dev/null
+ jq -e '.results[0].advisories[0].Package == "vendor/package-a"' "$output" >/dev/null
+ EOF
diff --git a/tests/cli/qa/audit/fixtures/bin/composer b/tests/cli/qa/audit/fixtures/bin/composer
new file mode 100755
index 0000000..1e8ffb9
--- /dev/null
+++ b/tests/cli/qa/audit/fixtures/bin/composer
@@ -0,0 +1,17 @@
+#!/usr/bin/env sh
+
+cat <<'JSON'
+{
+ "advisories": {
+ "vendor/package-a": [
+ {
+ "title": "Remote Code Execution",
+ "link": "https://example.com/advisory/1",
+ "cve": "CVE-2026-0001",
+ "affectedVersions": ">=1.0,<1.5"
+ }
+ ]
+ }
+}
+JSON
+exit 1
diff --git a/tests/cli/qa/audit/fixtures/project/composer.json b/tests/cli/qa/audit/fixtures/project/composer.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/tests/cli/qa/audit/fixtures/project/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/tests/cli/qa/audit/fixtures/project/src/Bad.php b/tests/cli/qa/audit/fixtures/project/src/Bad.php
new file mode 100644
index 0000000..15b5f13
--- /dev/null
+++ b/tests/cli/qa/audit/fixtures/project/src/Bad.php
@@ -0,0 +1,5 @@
+/dev/null
+ jq -e '(.missing | length == 1) and (.missing[0].name == "Beta")' "$output" >/dev/null
+ jq -e '(.warnings | length == 1) and (.warnings[0].path == "fixtures/src")' "$output" >/dev/null
+ EOF
diff --git a/tests/cli/qa/docblock/fixtures/src/a.go b/tests/cli/qa/docblock/fixtures/src/a.go
new file mode 100644
index 0000000..af5e783
--- /dev/null
+++ b/tests/cli/qa/docblock/fixtures/src/a.go
@@ -0,0 +1,6 @@
+//go:build ignore
+
+package sample
+
+// Alpha demonstrates a documented exported function.
+func Alpha() {}
diff --git a/tests/cli/qa/docblock/fixtures/src/b.go b/tests/cli/qa/docblock/fixtures/src/b.go
new file mode 100644
index 0000000..d8e7178
--- /dev/null
+++ b/tests/cli/qa/docblock/fixtures/src/b.go
@@ -0,0 +1,5 @@
+//go:build ignore
+
+package sample
+
+func Beta() {}
diff --git a/tests/cli/qa/docblock/fixtures/src/broken.go b/tests/cli/qa/docblock/fixtures/src/broken.go
new file mode 100644
index 0000000..d0d991b
--- /dev/null
+++ b/tests/cli/qa/docblock/fixtures/src/broken.go
@@ -0,0 +1,5 @@
+//go:build ignore
+
+package sample
+
+func Broken(
diff --git a/tests/cli/qa/fmt/Taskfile.yaml b/tests/cli/qa/fmt/Taskfile.yaml
new file mode 100644
index 0000000..9e1c7f7
--- /dev/null
+++ b/tests/cli/qa/fmt/Taskfile.yaml
@@ -0,0 +1,18 @@
+version: "3"
+
+tasks:
+ test:
+ cmds:
+ - |
+ bash <<'EOF'
+ set -euo pipefail
+ source ../../_lib/run.sh
+
+ go build -trimpath -ldflags="-s -w" -o bin/core ../_harness
+
+ cd fixtures/project
+ output="$(mktemp)"
+ export PATH="../bin:$PATH"
+ run_capture_stdout 0 "$output" ../../bin/core qa fmt --json
+ jq -e '.tool == "pint" and .changed == true and .files[0].path == "src/Bad.php"' "$output" >/dev/null
+ EOF
diff --git a/tests/cli/qa/fmt/fixtures/project/composer.json b/tests/cli/qa/fmt/fixtures/project/composer.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/tests/cli/qa/fmt/fixtures/project/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/tests/cli/qa/fmt/fixtures/project/src/Bad.php b/tests/cli/qa/fmt/fixtures/project/src/Bad.php
new file mode 100644
index 0000000..15b5f13
--- /dev/null
+++ b/tests/cli/qa/fmt/fixtures/project/src/Bad.php
@@ -0,0 +1,5 @@
+/dev/null
+ jq -e '.summary.passing == 1 and .summary.errors == 1' "$output" >/dev/null
+ jq -e '.repos[0].status == "error" and .repos[0].name == "beta"' "$output" >/dev/null
+ jq -e '.repos[1].status == "passing" and .repos[1].name == "alpha"' "$output" >/dev/null
+ EOF
diff --git a/tests/cli/qa/health/fixtures/bin/gh b/tests/cli/qa/health/fixtures/bin/gh
new file mode 100755
index 0000000..9c46786
--- /dev/null
+++ b/tests/cli/qa/health/fixtures/bin/gh
@@ -0,0 +1,26 @@
+#!/usr/bin/env sh
+
+case "$*" in
+ *"--repo forge/alpha"*)
+ cat <<'JSON'
+[
+ {
+ "status": "completed",
+ "conclusion": "success",
+ "name": "CI",
+ "headSha": "abc123",
+ "updatedAt": "2026-03-30T00:00:00Z",
+ "url": "https://example.com/alpha/run/1"
+ }
+]
+JSON
+ ;;
+ *"--repo forge/beta"*)
+ printf '%s\n' 'simulated workflow lookup failure' >&2
+ exit 1
+ ;;
+ *)
+ printf '%s\n' "unexpected gh invocation: $*" >&2
+ exit 1
+ ;;
+esac
diff --git a/tests/cli/qa/health/fixtures/repos.yaml b/tests/cli/qa/health/fixtures/repos.yaml
new file mode 100644
index 0000000..d783c64
--- /dev/null
+++ b/tests/cli/qa/health/fixtures/repos.yaml
@@ -0,0 +1,8 @@
+version: 1
+org: forge
+base_path: .
+repos:
+ alpha:
+ type: module
+ beta:
+ type: module
diff --git a/tests/cli/qa/infection/Taskfile.yaml b/tests/cli/qa/infection/Taskfile.yaml
new file mode 100644
index 0000000..1d53c25
--- /dev/null
+++ b/tests/cli/qa/infection/Taskfile.yaml
@@ -0,0 +1,22 @@
+version: "3"
+
+tasks:
+ test:
+ cmds:
+ - |
+ bash <<'EOF'
+ set -euo pipefail
+ source ../../_lib/run.sh
+
+ go build -trimpath -ldflags="-s -w" -o bin/core ../_harness
+
+ cd fixtures/project
+ output="$(mktemp)"
+ run_capture_all 1 "$output" ../../bin/core qa infection --min-msi 80 --min-covered-msi 90 --threads 8 --filter src --only-covered
+ grep -Fq "Mutation Testing" "$output"
+ grep -Fq -- "--min-msi=80" "$output"
+ grep -Fq -- "--min-covered-msi=90" "$output"
+ grep -Fq -- "--threads=8" "$output"
+ grep -Fq -- "--filter=src" "$output"
+ grep -Fq -- "--only-covered" "$output"
+ EOF
diff --git a/tests/cli/qa/infection/fixtures/project/composer.json b/tests/cli/qa/infection/fixtures/project/composer.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/tests/cli/qa/infection/fixtures/project/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/tests/cli/qa/infection/fixtures/project/infection.json b/tests/cli/qa/infection/fixtures/project/infection.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/tests/cli/qa/infection/fixtures/project/infection.json
@@ -0,0 +1 @@
+{}
diff --git a/tests/cli/qa/infection/fixtures/project/src/Bad.php b/tests/cli/qa/infection/fixtures/project/src/Bad.php
new file mode 100644
index 0000000..15b5f13
--- /dev/null
+++ b/tests/cli/qa/infection/fixtures/project/src/Bad.php
@@ -0,0 +1,5 @@
+/dev/null
+ jq -e '.categories[0].category == "needs_response" and .categories[0].issues[0].repo_name == "alpha"' "$output" >/dev/null
+ jq -e '.categories[0].issues[0].action_hint != ""' "$output" >/dev/null
+ jq -e '.fetch_errors[0].repo == "beta"' "$output" >/dev/null
+ EOF
diff --git a/tests/cli/qa/issues/fixtures/bin/gh b/tests/cli/qa/issues/fixtures/bin/gh
new file mode 100755
index 0000000..a8d3ba3
--- /dev/null
+++ b/tests/cli/qa/issues/fixtures/bin/gh
@@ -0,0 +1,42 @@
+#!/usr/bin/env sh
+
+case "$*" in
+ *"api user"*)
+ printf '%s\n' 'alice'
+ ;;
+ *"issue list --repo forge/alpha"*)
+ cat <<'JSON'
+[
+ {
+ "number": 7,
+ "title": "Clarify agent output",
+ "state": "OPEN",
+ "body": "Explain behaviour",
+ "createdAt": "2026-03-30T00:00:00Z",
+ "updatedAt": "2026-03-30T11:00:00Z",
+ "author": {"login": "bob"},
+ "assignees": {"nodes": [{"login": "alice"}]},
+ "labels": {"nodes": [{"name": "agent:ready"}]},
+ "comments": {
+ "totalCount": 1,
+ "nodes": [
+ {
+ "author": {"login": "carol"},
+ "createdAt": "2026-03-30T10:30:00Z"
+ }
+ ]
+ },
+ "url": "https://example.com/issues/7"
+ }
+]
+JSON
+ ;;
+ *"issue list --repo forge/beta"*)
+ printf '%s\n' 'simulated issue query failure' >&2
+ exit 1
+ ;;
+ *)
+ printf '%s\n' "unexpected gh invocation: $*" >&2
+ exit 1
+ ;;
+esac
diff --git a/tests/cli/qa/issues/fixtures/repos.yaml b/tests/cli/qa/issues/fixtures/repos.yaml
new file mode 100644
index 0000000..d783c64
--- /dev/null
+++ b/tests/cli/qa/issues/fixtures/repos.yaml
@@ -0,0 +1,8 @@
+version: 1
+org: forge
+base_path: .
+repos:
+ alpha:
+ type: module
+ beta:
+ type: module
diff --git a/tests/cli/qa/psalm/Taskfile.yaml b/tests/cli/qa/psalm/Taskfile.yaml
new file mode 100644
index 0000000..c7d0592
--- /dev/null
+++ b/tests/cli/qa/psalm/Taskfile.yaml
@@ -0,0 +1,17 @@
+version: "3"
+
+tasks:
+ test:
+ cmds:
+ - |
+ bash <<'EOF'
+ set -euo pipefail
+ source ../../_lib/run.sh
+
+ go build -trimpath -ldflags="-s -w" -o bin/core ../_harness
+
+ cd fixtures/project
+ output="$(mktemp)"
+ run_capture_stdout 1 "$output" ../../bin/core qa psalm --json
+ jq -e '.tool == "psalm" and .issues[0].file == "src/Bad.php" and .issues[0].line == 3' "$output" >/dev/null
+ EOF
diff --git a/tests/cli/qa/psalm/fixtures/project/composer.json b/tests/cli/qa/psalm/fixtures/project/composer.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/tests/cli/qa/psalm/fixtures/project/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/tests/cli/qa/psalm/fixtures/project/psalm.xml b/tests/cli/qa/psalm/fixtures/project/psalm.xml
new file mode 100644
index 0000000..f576a91
--- /dev/null
+++ b/tests/cli/qa/psalm/fixtures/project/psalm.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/tests/cli/qa/psalm/fixtures/project/src/Bad.php b/tests/cli/qa/psalm/fixtures/project/src/Bad.php
new file mode 100644
index 0000000..33bde41
--- /dev/null
+++ b/tests/cli/qa/psalm/fixtures/project/src/Bad.php
@@ -0,0 +1,5 @@
+/dev/null
+ jq -e '.mine | length == 0 and .requested | length == 1' "$output" >/dev/null
+ jq -e '.requested[0].number == 42 and .requested[0].title == "Refine agent output"' "$output" >/dev/null
+ jq -e '.fetch_errors[0].repo == "forge/example" and .fetch_errors[0].scope == "mine"' "$output" >/dev/null
+ EOF
diff --git a/tests/cli/qa/review/fixtures/bin/gh b/tests/cli/qa/review/fixtures/bin/gh
new file mode 100755
index 0000000..5fd840d
--- /dev/null
+++ b/tests/cli/qa/review/fixtures/bin/gh
@@ -0,0 +1,37 @@
+#!/usr/bin/env sh
+
+case "$*" in
+ *"pr list --state open --search author:@me --json"*)
+ printf '%s\n' 'simulated author query failure' >&2
+ exit 1
+ ;;
+ *"pr list --state open --search review-requested:@me --json"*)
+ cat <<'JSON'
+[
+ {
+ "number": 42,
+ "title": "Refine agent output",
+ "author": {"login": "alice"},
+ "state": "OPEN",
+ "isDraft": false,
+ "mergeable": "MERGEABLE",
+ "reviewDecision": "",
+ "url": "https://example.com/pull/42",
+ "headRefName": "feature/agent-output",
+ "createdAt": "2026-03-30T00:00:00Z",
+ "updatedAt": "2026-03-30T00:00:00Z",
+ "additions": 12,
+ "deletions": 3,
+ "changedFiles": 2,
+ "statusCheckRollup": {"contexts": []},
+ "reviewRequests": {"nodes": []},
+ "reviews": []
+ }
+]
+JSON
+ ;;
+ *)
+ printf '%s\n' "unexpected gh invocation: $*" >&2
+ exit 1
+ ;;
+esac
diff --git a/tests/cli/qa/security/Taskfile.yaml b/tests/cli/qa/security/Taskfile.yaml
new file mode 100644
index 0000000..8467a6b
--- /dev/null
+++ b/tests/cli/qa/security/Taskfile.yaml
@@ -0,0 +1,21 @@
+version: "3"
+
+tasks:
+ test:
+ cmds:
+ - |
+ bash <<'EOF'
+ set -euo pipefail
+ source ../../_lib/run.sh
+
+ go build -trimpath -ldflags="-s -w" -o bin/core ../_harness
+
+ cd fixtures/project
+ output="$(mktemp)"
+ export PATH="$(pwd)/../bin:$PATH"
+ run_capture_stdout 1 "$output" ../../bin/core qa security --json
+ jq -e '.summary.total == 4 and .summary.passed == 0' "$output" >/dev/null
+ jq -e '.summary.critical == 3 and .summary.high == 1' "$output" >/dev/null
+ jq -e '.checks[0].id == "app_key_set" and .checks[1].id == "composer_audit"' "$output" >/dev/null
+ jq -e '.checks[] | select(.id == "debug_mode") | .passed == false' "$output" >/dev/null
+ EOF
diff --git a/tests/cli/qa/security/fixtures/bin/composer b/tests/cli/qa/security/fixtures/bin/composer
new file mode 100755
index 0000000..1e8ffb9
--- /dev/null
+++ b/tests/cli/qa/security/fixtures/bin/composer
@@ -0,0 +1,17 @@
+#!/usr/bin/env sh
+
+cat <<'JSON'
+{
+ "advisories": {
+ "vendor/package-a": [
+ {
+ "title": "Remote Code Execution",
+ "link": "https://example.com/advisory/1",
+ "cve": "CVE-2026-0001",
+ "affectedVersions": ">=1.0,<1.5"
+ }
+ ]
+ }
+}
+JSON
+exit 1
diff --git a/tests/cli/qa/security/fixtures/project/.env b/tests/cli/qa/security/fixtures/project/.env
new file mode 100644
index 0000000..c85b6bc
--- /dev/null
+++ b/tests/cli/qa/security/fixtures/project/.env
@@ -0,0 +1,3 @@
+APP_DEBUG=true
+APP_KEY=short
+APP_URL=http://example.com
diff --git a/tests/cli/qa/security/fixtures/project/composer.json b/tests/cli/qa/security/fixtures/project/composer.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/tests/cli/qa/security/fixtures/project/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/tests/cli/qa/security/fixtures/project/src/Bad.php b/tests/cli/qa/security/fixtures/project/src/Bad.php
new file mode 100644
index 0000000..15b5f13
--- /dev/null
+++ b/tests/cli/qa/security/fixtures/project/src/Bad.php
@@ -0,0 +1,5 @@
+/dev/null
+ EOF
diff --git a/tests/cli/qa/stan/fixtures/project/composer.json b/tests/cli/qa/stan/fixtures/project/composer.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/tests/cli/qa/stan/fixtures/project/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/tests/cli/qa/stan/fixtures/project/phpstan.neon b/tests/cli/qa/stan/fixtures/project/phpstan.neon
new file mode 100644
index 0000000..7c47856
--- /dev/null
+++ b/tests/cli/qa/stan/fixtures/project/phpstan.neon
@@ -0,0 +1,2 @@
+parameters:
+ level: 5
diff --git a/tests/cli/qa/stan/fixtures/project/src/Bad.php b/tests/cli/qa/stan/fixtures/project/src/Bad.php
new file mode 100644
index 0000000..4fab7fe
--- /dev/null
+++ b/tests/cli/qa/stan/fixtures/project/src/Bad.php
@@ -0,0 +1,5 @@
+' "$output"
+ EOF
diff --git a/tests/cli/qa/test/fixtures/project/composer.json b/tests/cli/qa/test/fixtures/project/composer.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/tests/cli/qa/test/fixtures/project/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/tests/cli/qa/test/fixtures/project/tests/Unit/ExampleTest.php b/tests/cli/qa/test/fixtures/project/tests/Unit/ExampleTest.php
new file mode 100644
index 0000000..d3306d7
--- /dev/null
+++ b/tests/cli/qa/test/fixtures/project/tests/Unit/ExampleTest.php
@@ -0,0 +1,11 @@
+assertTrue(false);
+ }
+}
diff --git a/tests/cli/qa/test/fixtures/project/vendor/bin/phpunit b/tests/cli/qa/test/fixtures/project/vendor/bin/phpunit
new file mode 100755
index 0000000..6517de9
--- /dev/null
+++ b/tests/cli/qa/test/fixtures/project/vendor/bin/phpunit
@@ -0,0 +1,15 @@
+#!/usr/bin/env sh
+
+junit=""
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --log-junit)
+ shift
+ junit="$1"
+ ;;
+ esac
+ shift
+done
+
+printf '%s' '' >"$junit"
diff --git a/tests/cli/qa/watch/Taskfile.yaml b/tests/cli/qa/watch/Taskfile.yaml
new file mode 100644
index 0000000..073335e
--- /dev/null
+++ b/tests/cli/qa/watch/Taskfile.yaml
@@ -0,0 +1,22 @@
+version: "3"
+
+tasks:
+ test:
+ cmds:
+ - |
+ bash <<'EOF'
+ set -euo pipefail
+ source ../../_lib/run.sh
+
+ go build -trimpath -ldflags="-s -w" -o bin/core ../_harness
+
+ repo="$(cat fixtures/repo.txt)"
+ commit="$(cat fixtures/commit.txt)"
+ output="$(mktemp)"
+ export PATH="$(pwd)/fixtures/bin:$PATH"
+ run_capture_all 1 "$output" ./bin/core qa watch --repo "$repo" --commit "$commit" --timeout 1s
+ grep -Fq "forge/example" "$output"
+ grep -Fq "01234567" "$output"
+ grep -Fq "Job: Build" "$output"
+ grep -Fq "Error: fatal: build failed in src/app.go:17" "$output"
+ EOF
diff --git a/tests/cli/qa/watch/fixtures/bin/gh b/tests/cli/qa/watch/fixtures/bin/gh
new file mode 100755
index 0000000..6762a52
--- /dev/null
+++ b/tests/cli/qa/watch/fixtures/bin/gh
@@ -0,0 +1,46 @@
+#!/usr/bin/env sh
+
+case "$*" in
+ *"run list --repo forge/example --commit 0123456789abcdef"*)
+ cat <<'JSON'
+[
+ {
+ "databaseId": 7,
+ "name": "CI",
+ "displayTitle": "CI",
+ "status": "completed",
+ "conclusion": "failure",
+ "headSha": "0123456789abcdef",
+ "url": "https://example.com/workflows/7",
+ "createdAt": "2026-03-30T00:00:00Z",
+ "updatedAt": "2026-03-30T00:00:00Z"
+ }
+]
+JSON
+ ;;
+ *"run view 7 --repo forge/example --json jobs"*)
+ cat <<'JSON'
+{"jobs":[
+ {
+ "databaseId": 11,
+ "name": "Build",
+ "status": "completed",
+ "conclusion": "failure",
+ "url": "https://example.com/workflows/7/jobs/11",
+ "steps": [
+ {"name": "Compile", "status": "completed", "conclusion": "failure", "number": 3}
+ ]
+ }
+]}
+JSON
+ ;;
+ *"run view 7 --repo forge/example --log-failed"*)
+ cat <<'EOF'
+fatal: build failed in src/app.go:17
+EOF
+ ;;
+ *)
+ printf '%s\n' "unexpected gh invocation: $*" >&2
+ exit 1
+ ;;
+esac
diff --git a/tests/cli/qa/watch/fixtures/commit.txt b/tests/cli/qa/watch/fixtures/commit.txt
new file mode 100644
index 0000000..8d6a8d5
--- /dev/null
+++ b/tests/cli/qa/watch/fixtures/commit.txt
@@ -0,0 +1 @@
+0123456789abcdef
diff --git a/tests/cli/qa/watch/fixtures/repo.txt b/tests/cli/qa/watch/fixtures/repo.txt
new file mode 100644
index 0000000..8036d44
--- /dev/null
+++ b/tests/cli/qa/watch/fixtures/repo.txt
@@ -0,0 +1 @@
+forge/example