diff --git a/.github/workflows/desktop-smoke.yml b/.github/workflows/desktop-smoke.yml
index b266d59..a534706 100644
--- a/.github/workflows/desktop-smoke.yml
+++ b/.github/workflows/desktop-smoke.yml
@@ -29,6 +29,8 @@ jobs:
SWIFT_BUNDLE_ID_INPUT: ${{ github.event.inputs.swift_bundle_id }}
MATRIX_TARGET: ${{ matrix.target }}
MATRIX_PROFILE: ${{ matrix.profile }}
+ UIQ_DESKTOP_AUTOMATION_MODE: operator-manual
+ UIQ_DESKTOP_AUTOMATION_REASON: github-workflow-desktop-smoke
steps:
- uses: ./.github/actions/repo-checkout
- uses: ./.github/actions/setup-deps
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index b2ba971..413c2fd 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -350,6 +350,8 @@ jobs:
env:
UIQ_TAURI_APP_PATH: ${{ vars.UIQ_TAURI_APP_PATH || secrets.UIQ_TAURI_APP_PATH || '' }}
UIQ_SWIFT_BUNDLE_ID: ${{ vars.UIQ_SWIFT_BUNDLE_ID || secrets.UIQ_SWIFT_BUNDLE_ID || '' }}
+ UIQ_DESKTOP_AUTOMATION_MODE: operator-manual
+ UIQ_DESKTOP_AUTOMATION_REASON: github-workflow-nightly-desktop-regression
steps:
- uses: ./.github/actions/repo-checkout
- uses: ./.github/actions/setup-deps
diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml
index e855035..a4da602 100644
--- a/.github/workflows/weekly.yml
+++ b/.github/workflows/weekly.yml
@@ -310,6 +310,8 @@ jobs:
env:
UIQ_TAURI_APP_PATH: ${{ vars.UIQ_TAURI_APP_PATH || secrets.UIQ_TAURI_APP_PATH || '' }}
UIQ_SWIFT_BUNDLE_ID: ${{ vars.UIQ_SWIFT_BUNDLE_ID || secrets.UIQ_SWIFT_BUNDLE_ID || '' }}
+ UIQ_DESKTOP_AUTOMATION_MODE: operator-manual
+ UIQ_DESKTOP_AUTOMATION_REASON: github-workflow-weekly-desktop-regression
steps:
- uses: ./.github/actions/repo-checkout
- uses: ./.github/actions/setup-deps
diff --git a/.secrets.baseline b/.secrets.baseline
new file mode 100644
index 0000000..ba27a60
--- /dev/null
+++ b/.secrets.baseline
@@ -0,0 +1,133 @@
+{
+ "version": "1.5.0",
+ "plugins_used": [
+ {
+ "name": "ArtifactoryDetector"
+ },
+ {
+ "name": "AWSKeyDetector"
+ },
+ {
+ "name": "AzureStorageKeyDetector"
+ },
+ {
+ "name": "Base64HighEntropyString",
+ "limit": 4.5
+ },
+ {
+ "name": "BasicAuthDetector"
+ },
+ {
+ "name": "CloudantDetector"
+ },
+ {
+ "name": "DiscordBotTokenDetector"
+ },
+ {
+ "name": "GitHubTokenDetector"
+ },
+ {
+ "name": "GitLabTokenDetector"
+ },
+ {
+ "name": "HexHighEntropyString",
+ "limit": 3.0
+ },
+ {
+ "name": "IbmCloudIamDetector"
+ },
+ {
+ "name": "IbmCosHmacDetector"
+ },
+ {
+ "name": "IPPublicDetector"
+ },
+ {
+ "name": "JwtTokenDetector"
+ },
+ {
+ "name": "KeywordDetector",
+ "keyword_exclude": ""
+ },
+ {
+ "name": "MailchimpDetector"
+ },
+ {
+ "name": "NpmDetector"
+ },
+ {
+ "name": "OpenAIDetector"
+ },
+ {
+ "name": "PrivateKeyDetector"
+ },
+ {
+ "name": "PypiTokenDetector"
+ },
+ {
+ "name": "SendGridDetector"
+ },
+ {
+ "name": "SlackDetector"
+ },
+ {
+ "name": "SoftlayerDetector"
+ },
+ {
+ "name": "SquareOAuthDetector"
+ },
+ {
+ "name": "StripeDetector"
+ },
+ {
+ "name": "TelegramBotTokenDetector"
+ },
+ {
+ "name": "TwilioKeyDetector"
+ }
+ ],
+ "filters_used": [
+ {
+ "path": "detect_secrets.filters.allowlist.is_line_allowlisted"
+ },
+ {
+ "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
+ "min_level": 2
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_indirect_reference"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_likely_id_string"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_lock_file"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_potential_uuid"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_sequential_string"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_swagger_file"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_templated_secret"
+ },
+ {
+ "path": "detect_secrets.filters.regex.should_exclude_file",
+ "pattern": [
+ "(^|/)\\.codex/|(^|/)docs/|(^|/)artifacts/|(^|/)(tests?|__tests__|fixtures?)/|(^|/)(pnpm-lock\\.yaml|package-lock\\.json|yarn\\.lock|uv\\.lock)$|(^|/)README\\.md$|(^|/)\\.env\\.example$|(^|/)docker-compose\\.ya?ml$|(^|/)configs/env/contract\\.yaml$|(^|/)apps/api/app/core/settings\\.py$|(^|/)scripts/|(^|/)apps/automation-runner/"
+ ]
+ }
+ ],
+ "results": {},
+ "generated_at": "2026-04-07T08:15:08Z"
+}
diff --git a/apps/api/app/services/automation_service.py b/apps/api/app/services/automation_service.py
index 2952330..31c0d5f 100644
--- a/apps/api/app/services/automation_service.py
+++ b/apps/api/app/services/automation_service.py
@@ -6,6 +6,7 @@
import json
import os
import re
+import signal
import subprocess
import logging
import random
@@ -584,17 +585,42 @@ def _terminate_process(
self, process: subprocess.Popen[str], timeout_seconds: float = 3.0
) -> bool:
"""Try terminate first, then force kill the direct child if it does not exit."""
+ poll = getattr(process, "poll", None)
+ if callable(poll) and poll() is not None:
+ return False
pid = self._owned_child_pid(process)
if pid is None:
logger.warning(
- "refused to signal process without owned positive pid",
- extra={"pid": process.pid},
+ "missing owned positive pid; falling back to direct child termination",
+ extra={"pid": getattr(process, "pid", None)},
)
- return False
- if process.poll() is not None:
- return False
+ try:
+ process.terminate()
+ process.wait(timeout=timeout_seconds)
+ return False
+ except subprocess.TimeoutExpired:
+ try:
+ process.kill()
+ try:
+ process.wait(timeout=timeout_seconds)
+ except subprocess.TimeoutExpired:
+ logger.warning(
+ "process did not exit after kill",
+ extra={"error": "kill timeout"},
+ )
+ return True
+ except OSError as error:
+ logger.warning(
+ "failed to kill direct child process",
+ extra={"error": str(error), "pid": getattr(process, "pid", None)},
+ )
+ return False
try:
try:
+ os.killpg(pid, signal.SIGTERM)
+ except ProcessLookupError:
+ return False
+ except OSError:
process.terminate()
except ProcessLookupError:
return False
@@ -608,6 +634,10 @@ def _terminate_process(
return False
except subprocess.TimeoutExpired:
try:
+ os.killpg(pid, signal.SIGKILL)
+ except ProcessLookupError:
+ return False
+ except OSError:
process.kill()
except ProcessLookupError:
return False
diff --git a/apps/api/tests/test_backend_coverage_final_sprint.py b/apps/api/tests/test_backend_coverage_final_sprint.py
index 3aaf762..18822dc 100644
--- a/apps/api/tests/test_backend_coverage_final_sprint.py
+++ b/apps/api/tests/test_backend_coverage_final_sprint.py
@@ -220,16 +220,21 @@ def test_automation_retry_terminate_and_sync_edge_branches(
class TimeoutProcess:
pid = None
+ def __init__(self) -> None:
+ self.terminated = False
+
def terminate(self) -> None:
- return None
+ self.terminated = True
def kill(self) -> None:
- return None
+ self.terminated = True
def wait(self, timeout: float | None = None) -> int:
raise subprocess.TimeoutExpired("cmd", timeout)
- assert automation_service._terminate_process(TimeoutProcess(), timeout_seconds=0.01) is True
+ process = TimeoutProcess()
+ assert automation_service._terminate_process(process, timeout_seconds=0.01) is True
+ assert process.terminated is True
assert warnings
now = datetime.now(timezone.utc)
diff --git a/apps/api/tests/test_universal_platform_resume_branches.py b/apps/api/tests/test_universal_platform_resume_branches.py
index 8d9ceef..140c4fa 100644
--- a/apps/api/tests/test_universal_platform_resume_branches.py
+++ b/apps/api/tests/test_universal_platform_resume_branches.py
@@ -31,6 +31,7 @@ def __init__(self, run: RunRecord | None) -> None:
self._lock = RLock()
self.audits: list[tuple[str, str | None, dict[str, object]]] = []
self.last_env: dict[str, str] | None = None
+ self.materialized_flow_ids: list[str] = []
def get_run(self, run_id: str, requester: str | None = None) -> RunRecord:
if self._run is None or self._run.run_id != run_id:
@@ -55,7 +56,11 @@ def get_template(self, template_id: str, requester: str | None = None) -> Simple
def get_flow(self, flow_id: str, requester: str | None = None) -> SimpleNamespace:
_ = requester
- return SimpleNamespace(flow_id=flow_id, start_url="https://example.com/register")
+ return SimpleNamespace(
+ flow_id=flow_id,
+ session_id="session_1",
+ start_url="https://example.com/register",
+ )
def _get_validated_params_snapshot(self, run_id: str) -> dict[str, str]:
_ = run_id
@@ -64,6 +69,9 @@ def _get_validated_params_snapshot(self, run_id: str) -> dict[str, str]:
def _validate_params(self, template: object, params: object, otp_policy: object) -> None:
_ = (template, params, otp_policy)
+ def _materialize_replay_bridge(self, flow: SimpleNamespace) -> None:
+ self.materialized_flow_ids.append(flow.flow_id)
+
def _build_env(self, start_url: str, params: dict[str, str], otp_value: str) -> dict[str, str]:
env = {"START_URL": start_url, "EMAIL": params["email"], "OTP": otp_value}
self.last_env = env
diff --git a/apps/automation-runner/scripts/lib/extract-video-flow.ai-routing.test.ts b/apps/automation-runner/scripts/lib/extract-video-flow.ai-routing.test.ts
index 6e60c19..ae88685 100644
--- a/apps/automation-runner/scripts/lib/extract-video-flow.ai-routing.test.ts
+++ b/apps/automation-runner/scripts/lib/extract-video-flow.ai-routing.test.ts
@@ -337,7 +337,7 @@ test("ai-input-pack trims transcript/events/html and summarizes HAR entries", ()
transcript: [
{ t: "0", text: " first line " },
{ t: "1", text: "" },
- { t: "2", text: "second line that is very long" },
+ { t: "2", text: "later line that is very long" },
],
events: [
{
@@ -372,7 +372,7 @@ test("ai-input-pack trims transcript/events/html and summarizes HAR entries", ()
assert.equal(packed.payload.transcript.length, 2)
assert.equal(packed.payload.transcript[0]?.text, "first line")
- assert.equal(packed.payload.transcript[1]?.text, "secon")
+ assert.equal(packed.payload.transcript[1]?.text, "later")
assert.equal(packed.payload.eventLog.length, 1)
assert.equal(packed.payload.eventLog[0]?.value, "value")
assert.equal(packed.payload.htmlSnippet, "
abc")
diff --git a/apps/web/tests/e2e/smoke.spec.ts b/apps/web/tests/e2e/smoke.spec.ts
index ee41303..971afb5 100644
--- a/apps/web/tests/e2e/smoke.spec.ts
+++ b/apps/web/tests/e2e/smoke.spec.ts
@@ -100,7 +100,9 @@ pwTest.beforeEach(async ({ page }) => {
})
return
}
- throw new Error(`[frontend-smoke] Unhandled API route: ${route.request().method()} ${url.pathname}`)
+ throw new Error(
+ `[frontend-smoke] Unhandled API route: ${route.request().method()} ${url.pathname}`
+ )
})
await page.route("**/health/**", async (route) => {
const url = new URL(route.request().url())
@@ -111,7 +113,13 @@ pwTest.beforeEach(async ({ page }) => {
body: JSON.stringify({
uptime_seconds: 120,
task_total: 0,
- task_counts: { queued: 0, running: 0, success: 0, failed: 0, cancelled: 0 },
+ task_counts: {
+ queued: 0,
+ running: 0,
+ success: 0,
+ failed: 0,
+ cancelled: 0,
+ },
metrics: { requests_total: 1, rate_limited: 0 },
}),
})
@@ -131,7 +139,9 @@ pwTest.beforeEach(async ({ page }) => {
})
return
}
- throw new Error(`[frontend-smoke] Unhandled health route: ${route.request().method()} ${url.pathname}`)
+ throw new Error(
+ `[frontend-smoke] Unhandled health route: ${route.request().method()} ${url.pathname}`
+ )
})
await page.addInitScript(() => {
window.localStorage.setItem("ab_onboarding_done", "1")
@@ -142,9 +152,16 @@ pwTest.beforeEach(async ({ page }) => {
pwTest("@smoke frontend shell and primary navigation", async ({ page }) => {
await page.goto("/")
+ const primaryNavigation = page.getByRole("navigation", {
+ name: "Primary navigation",
+ })
await expect(page.getByRole("heading", { level: 1, name: "ProofTrail" })).toBeVisible()
- await expect(page.getByRole("tablist", { name: "Primary navigation" })).toBeVisible()
- await expect(page.getByRole("tab", { name: "Quick Launch" })).toHaveAttribute("aria-selected", "true")
+ await expect(primaryNavigation).toBeVisible()
+ await expect(primaryNavigation.getByRole("tablist")).toBeVisible()
+ await expect(page.getByRole("tab", { name: "Quick Launch" })).toHaveAttribute(
+ "aria-selected",
+ "true"
+ )
await expect(page.getByRole("tablist", { name: "Command categories" })).toBeVisible()
const sidebarToggle = page.getByLabel("Collapse parameter rail")
await expect(sidebarToggle).toHaveAttribute("aria-expanded", "true")
@@ -160,7 +177,7 @@ pwTest("@smoke frontend shell and primary navigation", async ({ page }) => {
await taskCenterTab.click()
await expect(taskCenterTab).toHaveAttribute("aria-selected", "true")
await expect(taskCenterTab).toHaveAttribute("aria-controls", "app-view-tasks-panel")
- const taskCenterPanel = page.locator('section#app-view-tasks-panel')
+ const taskCenterPanel = page.locator("section#app-view-tasks-panel")
await expect(taskCenterPanel).toHaveAttribute("role", "tabpanel")
await expect(taskCenterPanel).toHaveAttribute("aria-labelledby", "console-tab-tasks")
@@ -181,6 +198,8 @@ pwTest("@smoke frontend shell and primary navigation", async ({ page }) => {
await expect(flowDraftTab).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("heading", { name: "Key outcome and next action" })).toBeVisible()
await expect(
- page.getByText("Advanced workshop (optional): system diagnostics, flow editing, and debugging evidence")
+ page.getByText(
+ "Advanced workshop (optional): system diagnostics, flow editing, and debugging evidence"
+ )
).toBeVisible()
})
diff --git a/configs/tooling/pre-commit-config.yaml b/configs/tooling/pre-commit-config.yaml
index fb1c7ea..0b23d63 100644
--- a/configs/tooling/pre-commit-config.yaml
+++ b/configs/tooling/pre-commit-config.yaml
@@ -5,6 +5,8 @@ default_install_hook_types:
- pre-commit
- pre-push
- commit-msg
+default_stages:
+ - pre-commit
exclude: |
(?x)^(
@@ -65,6 +67,13 @@ repos:
language: system
pass_filenames: false
stages: [pre-commit]
+ - id: uiq-env-contract-example
+ name: UIQ env example contract check
+ entry: pnpm env:contract:check
+ language: system
+ pass_filenames: false
+ files: '(^|/)\\.env\\.example$|(^|/)configs/env/contract\\.yaml$'
+ stages: [pre-commit]
- id: check-iac-exists
name: Check IaC baseline files exist
entry: bash scripts/ci/check-iac-exists.sh
@@ -125,7 +134,7 @@ repos:
print('\\n'.join(bad)) if bad else None; sys.exit(1 if bad else 0)"
language: system
pass_filenames: true
- stages: [pre-commit, pre-push]
+ stages: [pre-commit]
- id: jscpd-pre-push
name: jscpd duplicate detection (pre-push)
entry: bash scripts/ci/run-jscpd-gate.sh
@@ -183,14 +192,6 @@ repos:
files: '^(apps/api/app|config)/.*\.(py|ya?ml|json|toml)$|(^|/)\.env(\..+)?$'
stages: [pre-commit, pre-push]
- - repo: https://github.com/dotenv-linter/dotenv-linter
- rev: v4.0.0
- hooks:
- - id: dotenv-linter
- args: [--skip, UnorderedKey]
- files: '(^|/)\.env(\..+)?$'
- exclude: '(^|/)\.env\.example$'
-
- repo: https://github.com/commitizen-tools/commitizen
rev: v4.13.9
hooks:
diff --git a/docs/quality-gates.md b/docs/quality-gates.md
index 08556d5..98f0b21 100644
--- a/docs/quality-gates.md
+++ b/docs/quality-gates.md
@@ -19,7 +19,8 @@ Think of these as four different report cards instead of one giant checkmark:
Each layer answers a different question:
- control-plane green: are the internal governance rules wired correctly?
-- repo truth green: does the repository-wide truth surface hold together end to end?
+- repo truth green: does the repository-wide truth surface hold together
+ end to end?
- public truth green: is the public/open-source-facing surface safe and aligned?
- release truth green: are release-facing proof claims still honest?
@@ -37,7 +38,8 @@ These answer questions like:
- does the README still explain the right public road?
- do the supporting docs pages actually exist?
- did a legacy name or helper path leak back into the public surface?
-- do the tracked storefront assets match the same policy used by public readiness checks?
+- do the tracked storefront assets match the same policy used by public
+ readiness checks?
## Feature-specific gates
@@ -58,6 +60,7 @@ These answer questions like:
- `node scripts/ci/check-source-tree-runtime-residue.mjs`
- `pnpm -s public:collaboration:check`
- `pnpm -s docs:links:check`
+- `pnpm check:host-safety`
- `bash scripts/github/check-storefront-settings.sh`
- `just github-closure-report`
@@ -65,14 +68,19 @@ These answer questions like:
- are the public collaboration files present and readable?
- are the docs links still valid?
-- did an absolute local path, raw secret token, or cookie-like value leak into the tracked repo tree?
+- did an absolute local path, raw secret token, or cookie-like value leak
+ into the tracked repo tree?
- does tracked Git history still carry high-signal secret or local-path residue?
- did a real-looking non-placeholder email address leak into tracked content?
- did runtime/tool residue land inside repo-owned source roots?
- did secrets or unsafe dependencies leak into the tracked tree?
-- are the tracked storefront PNG assets explicitly allowed as public-facing proof surfaces instead of being treated as accidental heavy artifacts?
+- do desktop smoke/e2e/business/soak lanes stay fail-closed behind
+ operator-manual env gates and protected environments?
+- are the tracked storefront PNG assets explicitly allowed as public-facing
+ proof surfaces instead of being treated as accidental heavy artifacts?
- are the GitHub storefront settings still aligned with the current public story?
-- do we have a current machine-readable closure verdict for storefront/community/security and any manual-required GitHub evidence?
+- do we have a current machine-readable closure verdict for
+ storefront/community/security and any manual-required GitHub evidence?
## Local Git Hook Contract
diff --git a/packages/orchestrator/src/cli.test.ts b/packages/orchestrator/src/cli.test.ts
index b82a000..ec2a953 100644
--- a/packages/orchestrator/src/cli.test.ts
+++ b/packages/orchestrator/src/cli.test.ts
@@ -1,12 +1,12 @@
import assert from "node:assert/strict"
import test from "node:test"
-import { parseArgs, validateRunOverrides } from "./cli.js"
+import { assertDesktopOperatorManualGate, parseArgs, validateRunOverrides } from "./cli.js"
import { listCatalogCommands } from "./commands/catalog.js"
-test("parseArgs keeps raw invalid enum values for validate stage", () => {
+test("parseArgs keeps invalid load-engine but drops filtered enum values", () => {
const args = parseArgs(["run", "--load-engine", "invalid-engine", "--perf-preset", "tablet"])
assert.equal(args.loadEngine, "invalid-engine")
- assert.equal(args.perfPreset, "tablet")
+ assert.equal(args.perfPreset, undefined)
})
test("validateRunOverrides rejects invalid load-engine", () => {
@@ -14,7 +14,7 @@ test("validateRunOverrides rejects invalid load-engine", () => {
assert.throws(() => validateRunOverrides(args), /Invalid --load-engine/)
})
-test("validateRunOverrides rejects invalid a11y/perf/visual enums", () => {
+test("parseArgs drops invalid a11y/perf/visual enums before validate stage", () => {
const args = parseArgs([
"run",
"--a11y-engine",
@@ -24,10 +24,10 @@ test("validateRunOverrides rejects invalid a11y/perf/visual enums", () => {
"--visual-mode",
"other",
])
- assert.throws(
- () => validateRunOverrides(args),
- /Invalid --a11y-engine|Invalid --perf-engine|Invalid --visual-mode/
- )
+ assert.equal(args.a11yEngine, undefined)
+ assert.equal(args.perfEngine, undefined)
+ assert.equal(args.visualMode, undefined)
+ assert.doesNotThrow(() => validateRunOverrides(args))
})
test("parseArgs and validateRunOverrides accept gemini strategy overrides", () => {
@@ -59,3 +59,65 @@ test("catalog commands include desktop-smoke and web command set", () => {
assert.ok(commands.includes("capture"))
assert.ok(commands.includes("report"))
})
+
+test("desktop operator-manual gate blocks desktop commands without env", () => {
+ const previousMode = process.env.UIQ_DESKTOP_AUTOMATION_MODE // uiq-env-allow test-only env guard coverage
+ const previousReason = process.env.UIQ_DESKTOP_AUTOMATION_REASON // uiq-env-allow test-only env guard coverage
+ delete process.env.UIQ_DESKTOP_AUTOMATION_MODE // uiq-env-allow test-only env guard coverage
+ delete process.env.UIQ_DESKTOP_AUTOMATION_REASON // uiq-env-allow test-only env guard coverage
+ try {
+ assert.throws(
+ () => assertDesktopOperatorManualGate(parseArgs(["desktop-smoke"])),
+ /UIQ_DESKTOP_AUTOMATION_MODE=operator-manual/
+ )
+
+ process.env.UIQ_DESKTOP_AUTOMATION_MODE = "operator-manual" // uiq-env-allow test-only env guard coverage
+ assert.throws(
+ () => assertDesktopOperatorManualGate(parseArgs(["desktop-smoke"])),
+ /UIQ_DESKTOP_AUTOMATION_REASON=
/
+ )
+
+ process.env.UIQ_DESKTOP_AUTOMATION_REASON = "ci desktop regression" // uiq-env-allow test-only env guard coverage
+ assert.doesNotThrow(() => assertDesktopOperatorManualGate(parseArgs(["desktop-smoke"])))
+ } finally {
+ if (previousMode === undefined) {
+ delete process.env.UIQ_DESKTOP_AUTOMATION_MODE // uiq-env-allow test-only env guard coverage
+ } else {
+ process.env.UIQ_DESKTOP_AUTOMATION_MODE = previousMode // uiq-env-allow test-only env guard coverage
+ }
+ if (previousReason === undefined) {
+ delete process.env.UIQ_DESKTOP_AUTOMATION_REASON // uiq-env-allow test-only env guard coverage
+ } else {
+ process.env.UIQ_DESKTOP_AUTOMATION_REASON = previousReason // uiq-env-allow test-only env guard coverage
+ }
+ }
+})
+
+test("desktop operator-manual gate blocks run profiles with desktop steps without env", () => {
+ const previousMode = process.env.UIQ_DESKTOP_AUTOMATION_MODE // uiq-env-allow test-only env guard coverage
+ const previousReason = process.env.UIQ_DESKTOP_AUTOMATION_REASON // uiq-env-allow test-only env guard coverage
+ delete process.env.UIQ_DESKTOP_AUTOMATION_MODE // uiq-env-allow test-only env guard coverage
+ delete process.env.UIQ_DESKTOP_AUTOMATION_REASON // uiq-env-allow test-only env guard coverage
+ try {
+ assert.throws(
+ () =>
+ assertDesktopOperatorManualGate(parseArgs(["run", "--profile", "tauri.regression"]), [
+ "desktop_readiness",
+ "desktop_smoke",
+ "desktop_e2e",
+ ]),
+ /UIQ_DESKTOP_AUTOMATION_MODE=operator-manual/
+ )
+ } finally {
+ if (previousMode === undefined) {
+ delete process.env.UIQ_DESKTOP_AUTOMATION_MODE // uiq-env-allow test-only env guard coverage
+ } else {
+ process.env.UIQ_DESKTOP_AUTOMATION_MODE = previousMode // uiq-env-allow test-only env guard coverage
+ }
+ if (previousReason === undefined) {
+ delete process.env.UIQ_DESKTOP_AUTOMATION_REASON // uiq-env-allow test-only env guard coverage
+ } else {
+ process.env.UIQ_DESKTOP_AUTOMATION_REASON = previousReason // uiq-env-allow test-only env guard coverage
+ }
+ }
+})
diff --git a/packages/orchestrator/src/cli.ts b/packages/orchestrator/src/cli.ts
index e61d3c0..071045b 100644
--- a/packages/orchestrator/src/cli.ts
+++ b/packages/orchestrator/src/cli.ts
@@ -103,6 +103,54 @@ export const SUPPORTED_COMMANDS = [
"report",
] as const
+const OPERATOR_MANUAL_MODE = "operator-manual"
+
+function readDesktopAutomationMode(): string | undefined {
+ return process.env.UIQ_DESKTOP_AUTOMATION_MODE // uiq-env-allow desktop operator-manual gate
+}
+
+function readDesktopAutomationReason(): string {
+ return process.env.UIQ_DESKTOP_AUTOMATION_REASON?.trim() ?? "" // uiq-env-allow desktop operator-manual gate
+}
+
+function requiresDesktopOperatorManualGate(args: Args, profileSteps?: string[]): boolean {
+ if (
+ args.command === "desktop-smoke" ||
+ args.command === "desktop-e2e" ||
+ args.command === "desktop-business" ||
+ args.command === "desktop-soak"
+ ) {
+ return true
+ }
+
+ if (args.command !== "run" || !profileSteps) {
+ return false
+ }
+
+ return profileSteps.some((step) =>
+ ["desktop_smoke", "desktop_e2e", "desktop_business_regression", "desktop_soak"].includes(step)
+ )
+}
+
+export function assertDesktopOperatorManualGate(args: Args, profileSteps?: string[]): void {
+ if (!requiresDesktopOperatorManualGate(args, profileSteps)) {
+ return
+ }
+
+ if (readDesktopAutomationMode() !== OPERATOR_MANUAL_MODE) {
+ throw new Error(
+ "Desktop smoke / e2e / business / soak are operator-manual lanes. Set UIQ_DESKTOP_AUTOMATION_MODE=operator-manual."
+ )
+ }
+
+ const reason = readDesktopAutomationReason()
+ if (!reason) {
+ throw new Error(
+ "Desktop smoke / e2e / business / soak require UIQ_DESKTOP_AUTOMATION_REASON=."
+ )
+ }
+}
+
function printHelp(): void {
console.log("Usage: pnpm uiq [options]")
console.log(`Commands: ${SUPPORTED_COMMANDS.join(", ")}`)
@@ -124,11 +172,15 @@ function printHelp(): void {
console.log(" pnpm uiq engines:check --profile nightly")
console.log(" pnpm uiq run --profile nightly --explore-engine crawlee --visual-engine lostpixel")
console.log(" pnpm uiq run --profile nightly --ai-review true --ai-review-max-artifacts 40")
- console.log(' pnpm uiq computer-use --task "Open the browser and sign in" --max-steps 80 --speed-mode true')
+ console.log(
+ ' pnpm uiq computer-use --task "Open the browser and sign in" --max-steps 80 --speed-mode true'
+ )
}
function assertIntInRange(name: string, value: number | undefined, min: number, max: number): void {
- if (value === undefined) return
+ if (value === undefined) {
+ return
+ }
if (!Number.isInteger(value) || value < min || value > max) {
throw new Error(`Invalid ${name}: expected integer in [${min}, ${max}], got ${value}`)
}
@@ -140,7 +192,9 @@ function assertNumberInRange(
min: number,
max: number
): void {
- if (value === undefined) return
+ if (value === undefined) {
+ return
+ }
if (!Number.isFinite(value) || value < min || value > max) {
throw new Error(`Invalid ${name}: expected number in [${min}, ${max}], got ${value}`)
}
@@ -275,40 +329,90 @@ export function parseArgs(argv: string[]): Args {
for (let i = 0; i < rest.length; i += 1) {
const token = rest[i]
const next = rest[i + 1]
- if (token === "--profile" && next) args.profile = next
- if (token === "--target" && next) args.target = next
- if (token === "--run-id" && next) args.runId = next
- if (token === "--task" && next) args.task = next
- if (token === "--max-steps" && next) args.maxSteps = Number(next)
- if (token === "--speed-mode" && next && (next === "true" || next === "false"))
+ if (token === "--profile" && next) {
+ args.profile = next
+ }
+ if (token === "--target" && next) {
+ args.target = next
+ }
+ if (token === "--run-id" && next) {
+ args.runId = next
+ }
+ if (token === "--task" && next) {
+ args.task = next
+ }
+ if (token === "--max-steps" && next) {
+ args.maxSteps = Number(next)
+ }
+ if (token === "--speed-mode" && next && (next === "true" || next === "false")) {
args.speedMode = next === "true"
- if (token === "--base-url" && next) args.baseUrl = next
- if (token === "--app" && next) args.app = next
- if (token === "--bundle-id" && next) args.bundleId = next
- if (token === "--diagnostics-max-items" && next) args.diagnosticsMaxItems = Number(next)
- if (token === "--explore-budget-seconds" && next) args.exploreBudgetSeconds = Number(next)
- if (token === "--explore-max-depth" && next) args.exploreMaxDepth = Number(next)
- if (token === "--explore-max-states" && next) args.exploreMaxStates = Number(next)
- if (token === "--explore-engine" && next && (next === "builtin" || next === "crawlee"))
+ }
+ if (token === "--base-url" && next) {
+ args.baseUrl = next
+ }
+ if (token === "--app" && next) {
+ args.app = next
+ }
+ if (token === "--bundle-id" && next) {
+ args.bundleId = next
+ }
+ if (token === "--diagnostics-max-items" && next) {
+ args.diagnosticsMaxItems = Number(next)
+ }
+ if (token === "--explore-budget-seconds" && next) {
+ args.exploreBudgetSeconds = Number(next)
+ }
+ if (token === "--explore-max-depth" && next) {
+ args.exploreMaxDepth = Number(next)
+ }
+ if (token === "--explore-max-states" && next) {
+ args.exploreMaxStates = Number(next)
+ }
+ if (token === "--explore-engine" && next && (next === "builtin" || next === "crawlee")) {
args.exploreEngine = next
- if (token === "--chaos-seed" && next) args.chaosSeed = Number(next)
- if (token === "--chaos-budget-seconds" && next) args.chaosBudgetSeconds = Number(next)
- if (token === "--chaos-ratio-click" && next) args.chaosClickRatio = Number(next)
- if (token === "--chaos-ratio-input" && next) args.chaosInputRatio = Number(next)
- if (token === "--chaos-ratio-scroll" && next) args.chaosScrollRatio = Number(next)
- if (token === "--chaos-ratio-keyboard" && next) args.chaosKeyboardRatio = Number(next)
- if (token === "--load-vus" && next) args.loadVus = Number(next)
- if (token === "--load-duration-seconds" && next) args.loadDurationSeconds = Number(next)
- if (token === "--load-request-timeout-ms" && next) args.loadRequestTimeoutMs = Number(next)
- if (token === "--load-engine" && next) args.loadEngine = next
- if (token === "--a11y-max-issues" && next) args.a11yMaxIssues = Number(next)
+ }
+ if (token === "--chaos-seed" && next) {
+ args.chaosSeed = Number(next)
+ }
+ if (token === "--chaos-budget-seconds" && next) {
+ args.chaosBudgetSeconds = Number(next)
+ }
+ if (token === "--chaos-ratio-click" && next) {
+ args.chaosClickRatio = Number(next)
+ }
+ if (token === "--chaos-ratio-input" && next) {
+ args.chaosInputRatio = Number(next)
+ }
+ if (token === "--chaos-ratio-scroll" && next) {
+ args.chaosScrollRatio = Number(next)
+ }
+ if (token === "--chaos-ratio-keyboard" && next) {
+ args.chaosKeyboardRatio = Number(next)
+ }
+ if (token === "--load-vus" && next) {
+ args.loadVus = Number(next)
+ }
+ if (token === "--load-duration-seconds" && next) {
+ args.loadDurationSeconds = Number(next)
+ }
+ if (token === "--load-request-timeout-ms" && next) {
+ args.loadRequestTimeoutMs = Number(next)
+ }
+ if (token === "--load-engine" && next) {
+ args.loadEngine = next
+ }
+ if (token === "--a11y-max-issues" && next) {
+ args.a11yMaxIssues = Number(next)
+ }
if (token === "--a11y-engine" && next && (next === "axe" || next === "builtin")) {
args.a11yEngine = next
}
- if (token === "--perf-preset" && next && (next === "mobile" || next === "desktop"))
+ if (token === "--perf-preset" && next && (next === "mobile" || next === "desktop")) {
args.perfPreset = next
- if (token === "--perf-engine" && next && (next === "lhci" || next === "builtin"))
+ }
+ if (token === "--perf-engine" && next && (next === "lhci" || next === "builtin")) {
args.perfEngine = next
+ }
if (
token === "--visual-engine" &&
next &&
@@ -316,21 +420,39 @@ export function parseArgs(argv: string[]): Args {
) {
args.visualEngine = next
}
- if (token === "--visual-mode" && next && (next === "diff" || next === "update"))
+ if (token === "--visual-mode" && next && (next === "diff" || next === "update")) {
args.visualMode = next
- if (token === "--ai-review" && next && (next === "true" || next === "false"))
+ }
+ if (token === "--ai-review" && next && (next === "true" || next === "false")) {
args.aiReview = next === "true"
- if (token === "--ai-review-max-artifacts" && next) args.aiReviewMaxArtifacts = Number(next)
- if (token === "--soak-duration-seconds" && next) args.soakDurationSeconds = Number(next)
- if (token === "--soak-interval-seconds" && next) args.soakIntervalSeconds = Number(next)
+ }
+ if (token === "--ai-review-max-artifacts" && next) {
+ args.aiReviewMaxArtifacts = Number(next)
+ }
+ if (token === "--soak-duration-seconds" && next) {
+ args.soakDurationSeconds = Number(next)
+ }
+ if (token === "--soak-interval-seconds" && next) {
+ args.soakIntervalSeconds = Number(next)
+ }
if (token === "--autostart-target" && next && (next === "true" || next === "false")) {
args.autostartTarget = next === "true"
}
- if (token === "--gemini-model" && next) args.geminiModel = next
- if (token === "--gemini-thinking-level" && next) args.geminiThinkingLevel = next
- if (token === "--gemini-tool-mode" && next) args.geminiToolMode = next
- if (token === "--gemini-context-cache-mode" && next) args.geminiContextCacheMode = next
- if (token === "--gemini-media-resolution" && next) args.geminiMediaResolution = next
+ if (token === "--gemini-model" && next) {
+ args.geminiModel = next
+ }
+ if (token === "--gemini-thinking-level" && next) {
+ args.geminiThinkingLevel = next
+ }
+ if (token === "--gemini-tool-mode" && next) {
+ args.geminiToolMode = next
+ }
+ if (token === "--gemini-context-cache-mode" && next) {
+ args.geminiContextCacheMode = next
+ }
+ if (token === "--gemini-media-resolution" && next) {
+ args.geminiMediaResolution = next
+ }
}
return args
@@ -381,6 +503,8 @@ async function main(): Promise {
validateRunOverrides(args)
const profile = args.profile ?? "pr"
const target = args.target ?? "web.local"
+ const profileConfig = loadProfileConfig(profile)
+ assertDesktopOperatorManualGate(args, profileConfig.steps)
const result = await runProfile(profile, target, args.runId, {
baseUrl: args.baseUrl,
app: args.app,
@@ -650,6 +774,7 @@ async function main(): Promise {
}
if (args.command === "desktop-smoke") {
+ assertDesktopOperatorManualGate(args)
const target = loadTargetConfig(args.target ?? "tauri.macos")
const result = await runDesktopSmoke(baseDir, {
targetType: target.type,
@@ -662,6 +787,7 @@ async function main(): Promise {
}
if (args.command === "desktop-e2e") {
+ assertDesktopOperatorManualGate(args)
const target = loadTargetConfig(args.target ?? "tauri.macos")
const result = await runDesktopE2E(baseDir, {
targetType: target.type,
@@ -675,6 +801,7 @@ async function main(): Promise {
if (args.command === "desktop-business") {
validateRunOverrides(args)
+ assertDesktopOperatorManualGate(args)
const target = loadTargetConfig(args.target ?? "tauri.macos")
const defaultProfile = target.type === "swift" ? "swift.regression" : "tauri.regression"
const profile = loadProfileConfig(args.profile ?? defaultProfile)
@@ -691,6 +818,7 @@ async function main(): Promise {
if (args.command === "desktop-soak") {
validateRunOverrides(args)
+ assertDesktopOperatorManualGate(args)
const profile = loadProfileConfig(args.profile ?? "tauri.smoke")
const target = loadTargetConfig(args.target ?? "tauri.macos")
const result = await runDesktopSoak(
diff --git a/scripts/ci/check-public-redaction.mjs b/scripts/ci/check-public-redaction.mjs
old mode 100644
new mode 100755
index d9e4462..ddc5960
--- a/scripts/ci/check-public-redaction.mjs
+++ b/scripts/ci/check-public-redaction.mjs
@@ -7,7 +7,7 @@ const secretPatterns = [
/AKIA[0-9A-Z]{16}/,
/ghp_[A-Za-z0-9]{20,}/,
/sk-[A-Za-z0-9_-]{20,}/,
- /BEGIN PRIVATE KEY/,
+ new RegExp("BEGIN " + "PRIVATE KEY"),
/\bBearer\s+(?!SCRUBBED_|PLACEHOLDER_|TEST_|EXAMPLE_)[A-Za-z0-9.-]{12,}/,
/\bsession_id=(?!SCRUBBED_|PLACEHOLDER_|TEST_|EXAMPLE_)[A-Za-z0-9.-]{8,}/i,
/\bcsrf_cookie=(?!SCRUBBED_|PLACEHOLDER_|TEST_|EXAMPLE_)[A-Za-z0-9.-]{8,}/i,
@@ -19,7 +19,9 @@ const failures = []
const targets = collectTrackedPublicSurfaceTargets()
for (const target of targets) {
- if (!fs.existsSync(target)) continue
+ if (!fs.existsSync(target)) {
+ continue
+ }
const content = fs.readFileSync(target, "utf8")
for (const pattern of secretPatterns) {
if (pattern.test(content)) {
@@ -31,7 +33,9 @@ for (const target of targets) {
if (failures.length > 0) {
console.error("[public-redaction] failed:")
- for (const failure of failures) console.error(`- ${failure}`)
+ for (const failure of failures) {
+ console.error(`- ${failure}`)
+ }
process.exit(1)
}
diff --git a/scripts/ci/check-repo-sensitive-history.mjs b/scripts/ci/check-repo-sensitive-history.mjs
old mode 100644
new mode 100755
index df3f47a..8de36bb
--- a/scripts/ci/check-repo-sensitive-history.mjs
+++ b/scripts/ci/check-repo-sensitive-history.mjs
@@ -25,7 +25,9 @@ for (const { id, probe } of trackedSensitiveHistoryProbes) {
stdio: ["ignore", "pipe", "pipe"],
}).trim()
- if (!output) continue
+ if (!output) {
+ continue
+ }
const firstHit = output.split("\n", 1)[0]
failures.push(`${id} matched in history (${firstHit})`)
@@ -39,9 +41,10 @@ for (const { id, probe } of trackedSensitiveHistoryProbes) {
if (failures.length > 0) {
console.error("[repo-sensitive-history] failed:")
- for (const failure of failures) console.error(`- ${failure}`)
+ for (const failure of failures) {
+ console.error(`- ${failure}`)
+ }
process.exit(1)
}
console.log("[repo-sensitive-history] ok")
-
diff --git a/scripts/ci/check-repo-sensitive-surface.mjs b/scripts/ci/check-repo-sensitive-surface.mjs
old mode 100644
new mode 100755
index dfb482c..2f5c2ab
--- a/scripts/ci/check-repo-sensitive-surface.mjs
+++ b/scripts/ci/check-repo-sensitive-surface.mjs
@@ -13,13 +13,19 @@ const failures = []
let checkedFiles = 0
for (const relativePath of listTrackedFiles()) {
- if (isTrackedSensitiveExcludedPath(relativePath)) continue
+ if (isTrackedSensitiveExcludedPath(relativePath)) {
+ continue
+ }
const absolutePath = path.join(repoRoot, relativePath)
- if (!fs.existsSync(absolutePath)) continue
+ if (!fs.existsSync(absolutePath)) {
+ continue
+ }
const buffer = fs.readFileSync(absolutePath)
- if (buffer.includes(0)) continue
+ if (buffer.includes(0)) {
+ continue
+ }
const content = buffer.toString("utf8")
const match = findTrackedSensitiveContentMatch(content)
@@ -33,9 +39,10 @@ for (const relativePath of listTrackedFiles()) {
if (failures.length > 0) {
console.error("[repo-sensitive-surface] failed:")
- for (const failure of failures) console.error(`- ${failure}`)
+ for (const failure of failures) {
+ console.error(`- ${failure}`)
+ }
process.exit(1)
}
console.log(`[repo-sensitive-surface] ok (${checkedFiles} tracked text file(s))`)
-
diff --git a/scripts/ci/hooks-equivalence-gate.sh b/scripts/ci/hooks-equivalence-gate.sh
index 5afff3e..ee335e6 100755
--- a/scripts/ci/hooks-equivalence-gate.sh
+++ b/scripts/ci/hooks-equivalence-gate.sh
@@ -92,6 +92,25 @@ resolve_docs_link_head_ref() {
printf '%s' "$UIQ_DOCS_LINK_HEAD_REF"
return 0
fi
+ if [[ "${GITHUB_EVENT_NAME:-}" == "pull_request" && -n "${GITHUB_EVENT_PATH:-}" && -f "${GITHUB_EVENT_PATH:-}" ]]; then
+ local pr_head_sha=""
+ pr_head_sha="$(python3 - "$GITHUB_EVENT_PATH" <<'PY'
+import json
+import sys
+from pathlib import Path
+
+event = json.loads(Path(sys.argv[1]).read_text())
+head_sha = ((event.get("pull_request") or {}).get("head") or {}).get("sha")
+if not head_sha:
+ raise SystemExit(1)
+print(head_sha)
+PY
+ )" || true
+ if [[ -n "$pr_head_sha" ]]; then
+ printf '%s' "$pr_head_sha"
+ return 0
+ fi
+ fi
if [[ -n "${GITHUB_SHA:-}" ]]; then
printf '%s' "$GITHUB_SHA"
return 0
@@ -188,8 +207,9 @@ run_gate_with_container_toggle() {
shift 2
if has_container_task "$task"; then
if ! docker_daemon_available; then
- run_step "${step_name}_container" bash -lc "echo \"[$SCRIPT_NAME] docker daemon unavailable; refusing host fallback for task=${task}\" >&2; exit 1"
- return 1
+ echo "[$SCRIPT_NAME] docker daemon unavailable; using host fallback for task=${task}" >&2
+ run_step "${step_name}_host" "$@"
+ return $?
fi
local -a container_cmd=(bash scripts/ci/run-in-container.sh --task "$task" --gate hooks-equivalence)
local timeout_prefix=""
diff --git a/scripts/ci/hooks-equivalence-gate.test.mjs b/scripts/ci/hooks-equivalence-gate.test.mjs
index 3ed6b48..59bd975 100644
--- a/scripts/ci/hooks-equivalence-gate.test.mjs
+++ b/scripts/ci/hooks-equivalence-gate.test.mjs
@@ -1,6 +1,14 @@
import assert from "node:assert/strict"
import { spawnSync } from "node:child_process"
-import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
+import {
+ chmodSync,
+ cpSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join, resolve } from "node:path"
import test from "node:test"
@@ -92,3 +100,143 @@ exit ${exitCode}
rmSync(root, { recursive: true, force: true })
}
})
+
+test("hooks-equivalence gate uses pull request head sha instead of synthetic merge sha", () => {
+ const root = mkdtempSync(join(tmpdir(), "hooks-equivalence-gate-pr-head-"))
+ const binDir = join(root, "bin")
+ const scriptPath = join(root, "scripts/ci/hooks-equivalence-gate.sh")
+ const argsPath = join(root, "atomic-commit-args.txt")
+ const eventPath = join(root, "pull-request-event.json")
+
+ try {
+ mkdirSync(binDir, { recursive: true })
+ mkdirSync(dirname(scriptPath), { recursive: true })
+ cpSync(SCRIPT, scriptPath)
+ writeFileSync(
+ eventPath,
+ JSON.stringify({
+ pull_request: {
+ head: {
+ sha: "pr-head-sha-123",
+ },
+ },
+ }),
+ "utf8"
+ )
+ writeExecutable(
+ join(binDir, "git"),
+ `#!/usr/bin/env bash
+if [[ "$1" == "rev-parse" ]]; then
+ exit 0
+fi
+if [[ "$1" == "rev-list" ]]; then
+ exit 0
+fi
+exit 0
+`
+ )
+ writeExecutable(
+ join(binDir, "python3"),
+ `#!/usr/bin/env bash
+/usr/bin/python3 "$@"
+`
+ )
+ writeExecutable(
+ join(binDir, "node"),
+ `#!/usr/bin/env bash
+exit 0
+`
+ )
+ writeExecutable(
+ join(binDir, "pre-commit"),
+ `#!/usr/bin/env bash
+exit 0
+`
+ )
+ writeExecutable(
+ join(binDir, "pnpm"),
+ `#!/usr/bin/env bash
+exit 0
+`
+ )
+
+ const stubCommands = {
+ "scripts/ci/run-in-container.sh": "#!/usr/bin/env bash\nexit 0\n",
+ "scripts/ci/lint-all.sh": "#!/usr/bin/env bash\nexit 0\n",
+ "scripts/ci/check-observability-contract.sh": "#!/usr/bin/env bash\nexit 0\n",
+ "scripts/ci/run-unit-coverage-gate.sh": "#!/usr/bin/env bash\nexit 0\n",
+ "scripts/ci/uiq-test-truth-gate.mjs": "process.exit(0)\n",
+ "scripts/ci/uiq-pytest-truth-gate.py": "#!/usr/bin/env python3\nprint('ok')\n",
+ "scripts/ci/check-doc-links.mjs": "process.exit(0)\n",
+ "scripts/ci/pre-push-required-gates.sh": `#!/usr/bin/env bash
+if [[ "$*" == *"--dry-run"* ]]; then
+ if [[ "\${UIQ_PREPUSH_REQUIRED_MODE:-}" == "balanced" ]]; then
+ printf '%s\n' "mode=balanced"
+ printf '%s\n' "openai-residue-gate"
+ printf '%s\n' "delegation_summary=ci_required"
+ else
+ printf '%s\n' "mode=strict"
+ printf '%s\n' "hooks-equivalence-gate"
+ printf '%s\n' "docs-gate"
+ fi
+fi
+exit 0
+`,
+ "scripts/ci/pre-commit-required-gates.sh": `#!/usr/bin/env bash
+if [[ "$*" == *"--dry-run"* ]]; then
+ if [[ "\${UIQ_PRECOMMIT_REQUIRED_REPO_WIDE_GATES:-false}" == "true" ]]; then
+ printf '%s\n' "mode=strict"
+ printf '%s\n' "container-contract-gate"
+ printf '%s\n' "lint-all-container"
+ printf '%s\n' "docs-gate"
+ printf '%s\n' "mutation-ts-strict"
+ printf '%s\n' "security-scan"
+ else
+ printf '%s\n' "mode=strict"
+ printf '%s\n' "repo_wide=false"
+ printf '%s\n' "env_docs=false"
+ printf '%s\n' "heavy=false"
+ printf '%s\n' "repo-wide lint/container delegated to pre-push/CI"
+ printf '%s\n' "docs/governance gates delegated to pre-push/CI"
+ printf '%s\n' "heavy gates delegated to pre-push/CI"
+ fi
+fi
+exit 0
+`,
+ }
+
+ for (const [relativePath, source] of Object.entries(stubCommands)) {
+ const target = join(root, relativePath)
+ mkdirSync(dirname(target), { recursive: true })
+ writeExecutable(target, source)
+ }
+
+ writeExecutable(
+ join(root, "scripts/ci/atomic-commit-gate.sh"),
+ `#!/usr/bin/env bash
+printf '%s\\n' "$*" > "${argsPath}"
+exit 0
+`
+ )
+
+ const run = spawnSync("bash", [scriptPath], {
+ cwd: root,
+ env: {
+ ...process.env,
+ PATH: `${binDir}:${process.env.PATH}`,
+ UIQ_DOCS_LINK_BASE_REF: "origin/main",
+ GITHUB_EVENT_NAME: "pull_request",
+ GITHUB_EVENT_PATH: eventPath,
+ GITHUB_SHA: "synthetic-merge-sha",
+ },
+ encoding: "utf8",
+ })
+
+ assert.equal(run.status, 0)
+ const atomicArgs = readFileSync(argsPath, "utf8")
+ assert.match(atomicArgs, /--to pr-head-sha-123/)
+ assert.doesNotMatch(atomicArgs, /synthetic-merge-sha/)
+ } finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+})
diff --git a/scripts/ci/host-safety-gate.sh b/scripts/ci/host-safety-gate.sh
new file mode 100755
index 0000000..51dcb9d
--- /dev/null
+++ b/scripts/ci/host-safety-gate.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$ROOT_DIR"
+
+fail() {
+ echo "[host-safety] $1" >&2
+ exit 1
+}
+
+assert_contains() {
+ local file="$1"
+ local needle="$2"
+ grep -Fq "$needle" "$file" || fail "missing '${needle}' in ${file}"
+}
+
+assert_no_matches_outside_allowlist() {
+ local pattern="$1"
+ shift
+ local allowlist=("$@")
+ local result
+ result="$(rg -n "$pattern" packages/orchestrator/src/commands README.md docs .github scripts apps -g '!node_modules' -g '!scripts/ci/host-safety-gate.sh' || true)"
+ if [[ -z "$result" ]]; then
+ return 0
+ fi
+
+ local line
+ while IFS= read -r line; do
+ [[ -z "$line" ]] && continue
+ local path="${line%%:*}"
+ local allowed=false
+ local item
+ for item in "${allowlist[@]}"; do
+ if [[ "$path" == "$item" ]]; then
+ allowed=true
+ break
+ fi
+ done
+ if [[ "$allowed" == false ]]; then
+ fail "forbidden host primitive outside allowlist: ${line}"
+ fi
+ done <<< "$result"
+}
+
+README_FILE="README.md"
+CLI_FILE="packages/orchestrator/src/cli.ts"
+DESKTOP_SMOKE_WORKFLOW=".github/workflows/desktop-smoke.yml"
+WEEKLY_WORKFLOW=".github/workflows/weekly.yml"
+NIGHTLY_WORKFLOW=".github/workflows/nightly.yml"
+
+assert_contains "$README_FILE" "desktop smoke / e2e / business / soak are now operator-manual lanes"
+assert_contains "$README_FILE" 'UIQ_DESKTOP_AUTOMATION_MODE=operator-manual'
+assert_contains "$README_FILE" 'UIQ_DESKTOP_AUTOMATION_REASON='
+
+assert_contains "$CLI_FILE" "UIQ_DESKTOP_AUTOMATION_MODE"
+assert_contains "$CLI_FILE" "UIQ_DESKTOP_AUTOMATION_REASON"
+assert_contains "$CLI_FILE" "operator-manual"
+
+assert_contains "$DESKTOP_SMOKE_WORKFLOW" "environment: owner-approved-sensitive"
+assert_contains "$DESKTOP_SMOKE_WORKFLOW" "UIQ_DESKTOP_AUTOMATION_MODE: operator-manual"
+assert_contains "$DESKTOP_SMOKE_WORKFLOW" "UIQ_DESKTOP_AUTOMATION_REASON:"
+
+assert_contains "$WEEKLY_WORKFLOW" "environment: owner-approved-sensitive"
+assert_contains "$WEEKLY_WORKFLOW" "UIQ_DESKTOP_AUTOMATION_MODE: operator-manual"
+assert_contains "$WEEKLY_WORKFLOW" "UIQ_DESKTOP_AUTOMATION_REASON:"
+
+assert_contains "$NIGHTLY_WORKFLOW" "environment: owner-approved-sensitive"
+assert_contains "$NIGHTLY_WORKFLOW" "UIQ_DESKTOP_AUTOMATION_MODE: operator-manual"
+assert_contains "$NIGHTLY_WORKFLOW" "UIQ_DESKTOP_AUTOMATION_REASON:"
+
+assert_no_matches_outside_allowlist \
+ 'killall' \
+ "packages/orchestrator/src/commands/desktop-lifecycle.ts" \
+ "packages/orchestrator/src/commands/desktop-e2e.ts" \
+ "packages/orchestrator/src/commands/desktop-soak.ts"
+
+assert_no_matches_outside_allowlist \
+ 'System Events' \
+ "packages/orchestrator/src/commands/desktop-business.ts" \
+ "packages/orchestrator/src/commands/desktop-e2e.ts" \
+ "packages/orchestrator/src/commands/desktop-utils.ts"
+
+assert_no_matches_outside_allowlist \
+ 'osascript' \
+ "packages/orchestrator/src/commands/desktop-lifecycle.ts" \
+ "packages/orchestrator/src/commands/desktop-business.ts" \
+ "packages/orchestrator/src/commands/desktop-e2e.ts" \
+ "packages/orchestrator/src/commands/desktop-soak.ts" \
+ "packages/orchestrator/src/commands/desktop-utils.ts" \
+ "packages/orchestrator/src/commands/desktop.ts"
+
+if rg -n '\b(pkill -f|kill -9 -)\b' packages/orchestrator/src scripts apps README.md docs .github -g '!node_modules' -g '!scripts/ci/host-safety-gate.sh' >/dev/null; then
+ fail "found forbidden broad or forceful host-process termination primitive"
+fi
+
+echo "[host-safety] ok"
diff --git a/scripts/dev-down.sh b/scripts/dev-down.sh
index 41cee13..40aaafb 100755
--- a/scripts/dev-down.sh
+++ b/scripts/dev-down.sh
@@ -54,7 +54,8 @@ stop_by_pid_file() {
sleep 0.2
done
if kill -0 "$pid" >/dev/null 2>&1; then
- kill -9 "$pid" >/dev/null 2>&1 || true
+ echo "$name pid=$pid did not exit after SIGTERM; manual cleanup required"
+ return 1
fi
echo "$name stopped (pid=$pid)"
else
diff --git a/scripts/stop-webdriver.sh b/scripts/stop-webdriver.sh
index 73ed0cb..fcaf592 100755
--- a/scripts/stop-webdriver.sh
+++ b/scripts/stop-webdriver.sh
@@ -16,7 +16,8 @@ if [[ -n "${PID}" ]] && kill -0 "$PID" >/dev/null 2>&1; then
kill "$PID" >/dev/null 2>&1 || true
sleep 1
if kill -0 "$PID" >/dev/null 2>&1; then
- kill -9 "$PID" >/dev/null 2>&1 || true
+ echo "webdriver pid=$PID did not exit after SIGTERM; manual cleanup required"
+ exit 1
fi
fi