@qatonic_innovations/qaios 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,18 @@ QAIOS acts like a QA engineer on your team. Point it at a feature description or
10
10
 
11
11
  **Mental model: Claude Code, but for QA work instead of feature coding.**
12
12
 
13
+ > **Status: early alpha (v0.1).** Core flows — `init`, `doctor`, test design
14
+ > & generation, Playwright execution, self-healing, accessibility, and the
15
+ > audit log — work today. Expect rough edges and please
16
+ > [report issues](https://github.com/qatonic/qaios/issues). After installing,
17
+ > a quick smoke test confirms everything resolves:
18
+ >
19
+ > ```bash
20
+ > qaios --version
21
+ > qaios --help
22
+ > qaios doctor # checks Node, API key, Playwright, config
23
+ > ```
24
+
13
25
  ---
14
26
 
15
27
  ## Install
@@ -23,18 +35,39 @@ The published package is `@qatonic_innovations/qaios`; the installed **command i
23
35
 
24
36
  ### Requirements
25
37
 
26
- - **Node.js 20 LTS or newer**
27
- - An **Anthropic API key** in your environment:
38
+ - **Node.js 20 LTS recommended.** QAIOS bundles a native SQLite module
39
+ (better-sqlite3) for its local audit log; Node 20 LTS has the widest
40
+ prebuilt-binary coverage. Newer Node usually works but may need to compile
41
+ the binary (build tools + network access).
42
+ - An **Anthropic API key** — get one at
43
+ [console.anthropic.com](https://console.anthropic.com/settings/keys), then
44
+ put it in your environment:
28
45
  ```bash
29
46
  export ANTHROPIC_API_KEY=sk-ant-... # macOS/Linux
30
- setx ANTHROPIC_API_KEY "sk-ant-..." # Windows (new shell after)
47
+ setx ANTHROPIC_API_KEY "sk-ant-..." # Windows (open a NEW shell after)
31
48
  ```
32
- - **Playwright** in your project, for `qaios run` / `snapshot` / `a11y`:
49
+ The key is read from the environment and **never written to disk** by QAIOS.
50
+ - **Playwright** in your project, for `qaios run` / `snapshot` / `explore` / `a11y`:
33
51
  ```bash
34
52
  npm i -D @playwright/test && npx playwright install
35
53
  ```
54
+ (`@playwright/test` is for `run`; `explore`/`a11y` use the `playwright`
55
+ package, which it pulls in.)
36
56
  - For `qaios a11y`, also: `npm i -D @axe-core/playwright`
37
57
 
58
+ ### Install troubleshooting
59
+
60
+ If `qaios init` fails with a SQLite/native-binding error
61
+ (`Could not load the native SQLite module`):
62
+
63
+ - Confirm your Node version: `node -v` (prefer 20 LTS).
64
+ - Rebuild the binary: `npm rebuild better-sqlite3` — or reinstall qaios.
65
+ - Behind a proxy/firewall? The prebuilt binary is fetched from GitHub
66
+ release assets; allow that host, or install C/C++ build tools so it can
67
+ compile locally.
68
+
69
+ `qaios doctor` will tell you exactly which check failed and what to run.
70
+
38
71
  ---
39
72
 
40
73
  ## 60-second quick start
@@ -126,8 +159,7 @@ mode: LITE # LITE | FULL | TRUST
126
159
  app:
127
160
  baseUrl: https://staging.myapp.com
128
161
  llm:
129
- provider: anthropic
130
- apiKeyEnv: ANTHROPIC_API_KEY # key is read from env, never stored
162
+ provider: anthropic # anthropic | openai
131
163
  maxLlmCallsPerWorkflow: 15
132
164
  costAlertThresholdUsdCents: 50
133
165
  testing:
@@ -143,6 +175,35 @@ defects:
143
175
 
144
176
  `qaios config show` prints the resolved config; `qaios config set <key> <value>` validates against the schema before writing. **API keys are never written to config** — they come from environment variables.
145
177
 
178
+ ### Choose your LLM provider
179
+
180
+ QAIOS works with **Anthropic** (default) or **OpenAI**. Set the provider in
181
+ `.qaios/config.yaml` and export that provider's key:
182
+
183
+ ```yaml
184
+ # Anthropic (default)
185
+ llm:
186
+ provider: anthropic # reads ANTHROPIC_API_KEY
187
+ ```
188
+
189
+ ```yaml
190
+ # OpenAI
191
+ llm:
192
+ provider: openai # reads OPENAI_API_KEY
193
+ model: gpt-4o # optional; default gpt-4o (try gpt-4o-mini for lower cost)
194
+ ```
195
+
196
+ ```bash
197
+ export OPENAI_API_KEY=sk-...
198
+ qaios doctor # confirms the configured provider's key is reachable
199
+ ```
200
+
201
+ The rest of QAIOS is provider-blind — every command (`test`, `run`, `fix`,
202
+ `explore`, `a11y`, …) works identically on either. Structured output uses each
203
+ provider's native guaranteed-schema mode (Anthropic forced tool-use / OpenAI
204
+ strict function calling), so generated artifacts stay schema-valid. You can
205
+ override the key's env-var name with `llm.apiKeyEnv` if needed.
206
+
146
207
  ### Operating modes
147
208
 
148
209
  - **LITE** (default) — HIGH/CRITICAL risk pauses for review; routine work flows through.
@@ -157,8 +218,27 @@ qaios config set mode TRUST
157
218
 
158
219
  ## Cost & privacy
159
220
 
160
- - All v0.1 skills use Claude Sonnet. A typical `qaios test` costs ~**$0.04–0.10**. Each workflow is capped (default `min(15 calls, $0.50)`, configurable) and aborts if exceeded.
161
- - **Local-first.** No telemetry, no phone-home. The only outbound traffic is (a) LLM calls to Anthropic with the prompts QAIOS builds, and (b) any MCP servers you configure. You bring your own API key; QAIOS does not proxy your requests.
221
+ - Skills run on Claude Sonnet (Anthropic) or gpt-4o (OpenAI) per your `llm.provider`. A typical `qaios test` costs ~**$0.04–0.10**. Each workflow is capped (default `min(15 calls, $0.50)`, configurable) and aborts if exceeded.
222
+ - **Local-first.** No telemetry, no phone-home. The only outbound traffic is (a) LLM calls to your configured provider (Anthropic or OpenAI) with the prompts QAIOS builds, and (b) any MCP servers you configure. You bring your own API key; QAIOS does not proxy your requests. See [SECURITY.md](https://github.com/qatonic/qaios/blob/main/SECURITY.md) for exactly what's sent.
223
+
224
+ ---
225
+
226
+ ## Exit codes (for CI)
227
+
228
+ Every command exits with a stable, documented code so you can gate pipelines:
229
+
230
+ | Code | Meaning | Example |
231
+ | ----- | ------------------------------- | ----------------------------------------------------------------------- |
232
+ | `0` | Success | tests generated; `doctor` all-green; `a11y` clean |
233
+ | `1` | User error / actionable failure | bad flag; `a11y` found real violations; `run` had failing tests |
234
+ | `2` | Gate blocked (informational) | a workflow paused for human review — run `qaios review` or pass `--yes` |
235
+ | `3` | Tool/dependency error | Playwright or axe not installed; SQLite binding missing |
236
+ | `4` | LLM error | rate limit, timeout, or invalid API key |
237
+ | `5` | Internal error | unexpected crash (re-run with `--debug` for a stack trace) |
238
+ | `130` | Cancelled | you pressed Ctrl+C |
239
+
240
+ `qaios doctor --json` and `--json` on most commands emit machine-readable
241
+ output for CI consumption.
162
242
 
163
243
  ---
164
244
 
@@ -19,6 +19,26 @@
19
19
  // 0 = success (even when violations were found)
20
20
 
21
21
  import { writeFileSync } from 'node:fs';
22
+ import { createRequire } from 'node:module';
23
+ import path from 'node:path';
24
+ import { pathToFileURL } from 'node:url';
25
+
26
+ // Resolve a package from the USER's project (process.cwd()), not from
27
+ // where this script physically lives (inside QAIOS's install dir). With a
28
+ // bare `import('playwright')`, Node resolves relative to THIS file — so a
29
+ // globally-installed QAIOS can't see the user's locally-installed
30
+ // playwright. Resolving via a require rooted at the project's package.json
31
+ // (falling back to cwd) and importing the resolved file URL fixes that.
32
+ const projectRequire = createRequire(path.join(process.cwd(), 'package.json'));
33
+ // Resolve from the user's project, but import the BARE specifier so Node
34
+ // honors the package's `exports` map (giving the proper ESM shape). We only
35
+ // use require.resolve to (a) verify the package exists in the project and
36
+ // (b) make the bare import resolvable, by importing the resolved entry and
37
+ // reading a named property off it. Returns the live module namespace.
38
+ const importFromProject = async (specifier) => {
39
+ const resolved = projectRequire.resolve(specifier); // throws if not installed
40
+ return import(pathToFileURL(resolved).href);
41
+ };
22
42
 
23
43
  const url = process.env.QAIOS_AXE_URL;
24
44
  const out = process.env.QAIOS_AXE_OUTPUT;
@@ -36,8 +56,14 @@ if (!url || !out) {
36
56
 
37
57
  let chromium, AxeBuilder;
38
58
  try {
39
- ({ chromium } = await import('playwright'));
40
- ({ default: AxeBuilder } = await import('@axe-core/playwright'));
59
+ // Importing a CJS entry via file URL nests the package's exports under
60
+ // `default`; importing an ESM entry exposes them as named. Read from
61
+ // whichever is present so both layouts work.
62
+ const pw = await importFromProject('playwright');
63
+ chromium = pw.chromium ?? pw.default?.chromium;
64
+ const axe = await importFromProject('@axe-core/playwright');
65
+ AxeBuilder = axe.default ?? axe.AxeBuilder;
66
+ if (!chromium || !AxeBuilder) throw new Error('resolved module missing expected export');
41
67
  } catch (err) {
42
68
  process.stderr.write(
43
69
  `axe-runner: failed to load 'playwright' and '@axe-core/playwright' from the project — ` +
@@ -12,6 +12,20 @@
12
12
  // via truncateHtmlSafe so the script stays decoupled from the budget.
13
13
 
14
14
  import { writeFileSync } from 'node:fs';
15
+ import { createRequire } from 'node:module';
16
+ import path from 'node:path';
17
+ import { pathToFileURL } from 'node:url';
18
+
19
+ // Resolve `playwright` from the USER's project (process.cwd()), not from
20
+ // where this script lives inside QAIOS's install dir. A bare
21
+ // `import('playwright')` resolves relative to THIS file, so a globally
22
+ // installed QAIOS can't see the user's local playwright. See axe-runner.mjs
23
+ // for the full rationale.
24
+ const projectRequire = createRequire(path.join(process.cwd(), 'package.json'));
25
+ const importFromProject = async (specifier) => {
26
+ const resolved = projectRequire.resolve(specifier); // throws if not installed
27
+ return import(pathToFileURL(resolved).href);
28
+ };
15
29
 
16
30
  const url = process.env.QAIOS_CAPTURE_URL;
17
31
  const out = process.env.QAIOS_CAPTURE_OUTPUT;
@@ -27,7 +41,9 @@ if (!url || !out) {
27
41
 
28
42
  let chromium;
29
43
  try {
30
- ({ chromium } = await import('playwright'));
44
+ const pw = await importFromProject('playwright');
45
+ chromium = pw.chromium ?? pw.default?.chromium;
46
+ if (!chromium) throw new Error('playwright resolved but has no chromium export');
31
47
  } catch (err) {
32
48
  process.stderr.write(
33
49
  `capture-page: failed to load 'playwright' from project — is it installed? ${err?.message ?? err}\n`,
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { useApp, useInput, Box, Text } from 'ink';
3
3
  import { useState } from 'react';
4
4
  import { jsx, jsxs } from 'react/jsx-runtime';
5
- import { readFileSync, existsSync, rmSync, mkdirSync, writeFileSync, statSync, mkdtempSync, copyFileSync, readdirSync } from 'fs';
5
+ import { realpathSync, readFileSync, existsSync, rmSync, mkdirSync, writeFileSync, statSync, mkdtempSync, copyFileSync, readdirSync } from 'fs';
6
6
  import path12 from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { Command, InvalidArgumentError } from 'commander';
@@ -11,6 +11,7 @@ import { createHash } from 'crypto';
11
11
  import { z, ZodError } from 'zod';
12
12
  import { monotonicFactory } from 'ulid';
13
13
  import Anthropic from '@anthropic-ai/sdk';
14
+ import OpenAI from 'openai';
14
15
  import { zodToJsonSchema } from 'zod-to-json-schema';
15
16
  import { spawn, spawnSync } from 'child_process';
16
17
  import { tmpdir } from 'os';
@@ -843,14 +844,23 @@ var QaiosConfig = z.object({
843
844
  version: z.literal(1),
844
845
  mode: Mode.default("LITE"),
845
846
  llm: z.object({
846
- provider: z.literal("anthropic").default("anthropic"),
847
- apiKeyEnv: z.string().default("ANTHROPIC_API_KEY"),
848
- // v0.1: every tier maps to claude-sonnet-4-6 (the current Sonnet
849
- // when v0.1 went GA — see PRICING table in runtime/llm/cost.ts).
850
- // There is no real tier routing yet. The skill-declared tier
851
- // (1/2/3) is recorded in the audit log so v0.5 can light up real
852
- // per-tier routing without re-tagging skills. Do not branch on
853
- // tier in skill code; treat the resolved model as opaque.
847
+ // Which LLM provider backs every skill. Default stays anthropic so
848
+ // existing projects are unchanged. Set `openai` to use OpenAI instead;
849
+ // the provider's own key env var (ANTHROPIC_API_KEY / OPENAI_API_KEY)
850
+ // is read automatically — see runtime/config/env.ts + llm/factory.ts.
851
+ provider: z.enum(["anthropic", "openai"]).default("anthropic"),
852
+ // Optional override of the env var that holds the API key. When unset,
853
+ // the factory picks the provider's conventional var.
854
+ apiKeyEnv: z.string().optional(),
855
+ // Optional explicit model id. When set, it overrides the per-tier
856
+ // models below for ALL tiers (single-model v0.1 behavior). When unset,
857
+ // the factory resolves the provider's default (Sonnet for anthropic,
858
+ // gpt-4o for openai).
859
+ model: z.string().optional(),
860
+ // v0.1: every tier maps to one model (claude-sonnet-4-6 for anthropic).
861
+ // There is no real tier routing yet. The skill-declared tier (1/2/3) is
862
+ // recorded in the audit log so v0.5 can light up real per-tier routing
863
+ // without re-tagging skills. Do not branch on tier in skill code.
854
864
  models: z.object({
855
865
  tier1: z.string().default("claude-sonnet-4-6"),
856
866
  // v0.5 → opus
@@ -2033,18 +2043,29 @@ function computeEntryHash(entry) {
2033
2043
  const { hash: _ignored, ...rest } = entry;
2034
2044
  return sha256Hex2(canonicalize(rest));
2035
2045
  }
2036
- function readAnthropicApiKey() {
2037
- const v = process.env["ANTHROPIC_API_KEY"];
2046
+ function readKey(name) {
2047
+ const v = process.env[name];
2038
2048
  if (typeof v !== "string") return void 0;
2039
2049
  return v.trim().length > 0 ? v : void 0;
2040
2050
  }
2051
+ function readAnthropicApiKey() {
2052
+ return readKey("ANTHROPIC_API_KEY");
2053
+ }
2054
+ function readOpenAiApiKey() {
2055
+ return readKey("OPENAI_API_KEY");
2056
+ }
2057
+ function readProviderApiKey(provider, apiKeyEnvOverride) {
2058
+ if (apiKeyEnvOverride !== void 0 && apiKeyEnvOverride.trim().length > 0) {
2059
+ return readKey(apiKeyEnvOverride);
2060
+ }
2061
+ return provider === "openai" ? readOpenAiApiKey() : readAnthropicApiKey();
2062
+ }
2041
2063
  function snapshotEnv() {
2042
- const k = readAnthropicApiKey();
2064
+ const a = readAnthropicApiKey();
2065
+ const o = readOpenAiApiKey();
2043
2066
  return {
2044
- anthropicApiKey: {
2045
- present: k !== void 0,
2046
- length: k?.length ?? 0
2047
- }
2067
+ anthropicApiKey: { present: a !== void 0, length: a?.length ?? 0 },
2068
+ openaiApiKey: { present: o !== void 0, length: o?.length ?? 0 }
2048
2069
  };
2049
2070
  }
2050
2071
  var PRICING = {
@@ -2079,16 +2100,52 @@ var PRICING = {
2079
2100
  outputPerMTok: 4,
2080
2101
  cacheReadPerMTok: 0.08,
2081
2102
  cacheWritePerMTok: 1
2103
+ },
2104
+ // ── OpenAI (provider: openai) ──────────────────────────────────────────
2105
+ // USD per 1M tokens. OpenAI bills cached input at ~0.5× the input rate;
2106
+ // there is no separate cache-write charge, so cacheWritePerMTok is 0.
2107
+ "gpt-4o": {
2108
+ inputPerMTok: 2.5,
2109
+ outputPerMTok: 10,
2110
+ cacheReadPerMTok: 1.25,
2111
+ cacheWritePerMTok: 0
2112
+ },
2113
+ "gpt-4o-mini": {
2114
+ inputPerMTok: 0.15,
2115
+ outputPerMTok: 0.6,
2116
+ cacheReadPerMTok: 0.075,
2117
+ cacheWritePerMTok: 0
2118
+ },
2119
+ "gpt-4.1": {
2120
+ inputPerMTok: 2,
2121
+ outputPerMTok: 8,
2122
+ cacheReadPerMTok: 0.5,
2123
+ cacheWritePerMTok: 0
2124
+ },
2125
+ "gpt-4.1-mini": {
2126
+ inputPerMTok: 0.4,
2127
+ outputPerMTok: 1.6,
2128
+ cacheReadPerMTok: 0.1,
2129
+ cacheWritePerMTok: 0
2082
2130
  }
2083
2131
  };
2084
2132
  var DEFAULT_PRICING = PRICING["claude-sonnet-4-6"];
2133
+ var _unknownModelWarned = /* @__PURE__ */ new Set();
2085
2134
  function computeCostUsdCents(model, usage) {
2086
- const p = PRICING[model] ?? DEFAULT_PRICING;
2135
+ const p = PRICING[model];
2136
+ if (p === void 0 && !_unknownModelWarned.has(model)) {
2137
+ _unknownModelWarned.add(model);
2138
+ process.stderr.write(
2139
+ `(qaios) warning: no pricing for model "${model}" \u2014 billing at the default rate. Costs in the audit log are approximate for this model.
2140
+ `
2141
+ );
2142
+ }
2143
+ const pricing = p ?? DEFAULT_PRICING;
2087
2144
  const tok = (n) => typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0;
2088
- const inputUsd = tok(usage.inputTokens) / 1e6 * p.inputPerMTok;
2089
- const outputUsd = tok(usage.outputTokens) / 1e6 * p.outputPerMTok;
2090
- const cacheReadUsd = tok(usage.cacheReadTokens) / 1e6 * p.cacheReadPerMTok;
2091
- const cacheWriteUsd = tok(usage.cacheWriteTokens) / 1e6 * p.cacheWritePerMTok;
2145
+ const inputUsd = tok(usage.inputTokens) / 1e6 * pricing.inputPerMTok;
2146
+ const outputUsd = tok(usage.outputTokens) / 1e6 * pricing.outputPerMTok;
2147
+ const cacheReadUsd = tok(usage.cacheReadTokens) / 1e6 * pricing.cacheReadPerMTok;
2148
+ const cacheWriteUsd = tok(usage.cacheWriteTokens) / 1e6 * pricing.cacheWritePerMTok;
2092
2149
  const totalCents = (inputUsd + outputUsd + cacheReadUsd + cacheWriteUsd) * 100;
2093
2150
  return Math.ceil(totalCents);
2094
2151
  }
@@ -2177,6 +2234,260 @@ function mapResponse(response, latencyMs) {
2177
2234
  stopReason: response.stop_reason
2178
2235
  };
2179
2236
  }
2237
+ var UNSUPPORTED_KEYWORDS = /* @__PURE__ */ new Set([
2238
+ "minLength",
2239
+ "maxLength",
2240
+ "pattern",
2241
+ "format",
2242
+ "minimum",
2243
+ "maximum",
2244
+ "exclusiveMinimum",
2245
+ "exclusiveMaximum",
2246
+ "multipleOf",
2247
+ "minItems",
2248
+ "maxItems",
2249
+ "uniqueItems",
2250
+ "minProperties",
2251
+ "maxProperties",
2252
+ "default",
2253
+ "$schema",
2254
+ "patternProperties"
2255
+ ]);
2256
+ function isPlainObject(v) {
2257
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2258
+ }
2259
+ function makeNullable(node) {
2260
+ const t = node["type"];
2261
+ if (typeof t === "string") {
2262
+ if (t === "null") return node;
2263
+ return { ...node, type: [t, "null"] };
2264
+ }
2265
+ if (Array.isArray(t)) {
2266
+ return t.includes("null") ? node : { ...node, type: [...t, "null"] };
2267
+ }
2268
+ return { anyOf: [node, { type: "null" }] };
2269
+ }
2270
+ function resolveRef(ref, root) {
2271
+ if (!ref.startsWith("#/")) return void 0;
2272
+ const parts = ref.slice(2).split("/").map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
2273
+ let cur = root;
2274
+ for (const part of parts) {
2275
+ if (!isPlainObject(cur) && !Array.isArray(cur)) return void 0;
2276
+ cur = cur[part];
2277
+ }
2278
+ return isPlainObject(cur) ? cur : void 0;
2279
+ }
2280
+ function inlineRefs(node, root, seen) {
2281
+ if (Array.isArray(node)) return node.map((n) => inlineRefs(n, root, seen));
2282
+ if (!isPlainObject(node)) return node;
2283
+ const ref = node["$ref"];
2284
+ if (typeof ref === "string") {
2285
+ if (seen.has(ref)) return { ...node };
2286
+ const target = resolveRef(ref, root);
2287
+ if (target !== void 0) {
2288
+ const next = new Set(seen).add(ref);
2289
+ const { $ref: _drop, ...siblings } = node;
2290
+ const inlined = inlineRefs(target, root, next);
2291
+ return { ...inlined, ...siblings };
2292
+ }
2293
+ }
2294
+ const out = {};
2295
+ for (const [k, v] of Object.entries(node)) out[k] = inlineRefs(v, root, seen);
2296
+ return out;
2297
+ }
2298
+ function openaiStrictify(schema) {
2299
+ if (!isPlainObject(schema)) return schema;
2300
+ return strictifyNode(inlineRefs(schema, schema, /* @__PURE__ */ new Set()));
2301
+ }
2302
+ function strictifyNode(schema) {
2303
+ if (!isPlainObject(schema)) return schema;
2304
+ const out = {};
2305
+ for (const [key, value] of Object.entries(schema)) {
2306
+ if (UNSUPPORTED_KEYWORDS.has(key)) continue;
2307
+ if (key === "properties" && isPlainObject(value)) {
2308
+ const props = {};
2309
+ for (const [propName, propSchema] of Object.entries(value)) {
2310
+ props[propName] = strictifyNode(propSchema);
2311
+ }
2312
+ out["properties"] = props;
2313
+ continue;
2314
+ }
2315
+ if (key === "items") {
2316
+ out["items"] = Array.isArray(value) ? value.map((v) => strictifyNode(v)) : strictifyNode(value);
2317
+ continue;
2318
+ }
2319
+ if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
2320
+ out[key] = value.map((v) => strictifyNode(v));
2321
+ continue;
2322
+ }
2323
+ out[key] = value;
2324
+ }
2325
+ if (out["type"] === "object" && isPlainObject(out["properties"])) {
2326
+ const props = out["properties"];
2327
+ const originalRequired = new Set(
2328
+ Array.isArray(schema["required"]) ? schema["required"] : []
2329
+ );
2330
+ const allKeys = Object.keys(props);
2331
+ for (const k of allKeys) {
2332
+ if (!originalRequired.has(k)) {
2333
+ props[k] = makeNullable(props[k]);
2334
+ }
2335
+ }
2336
+ out["required"] = allKeys;
2337
+ out["additionalProperties"] = false;
2338
+ }
2339
+ return out;
2340
+ }
2341
+ var DEFAULT_OPENAI_MODEL = "gpt-4o";
2342
+ var DEFAULT_MAX_TOKENS2 = 4096;
2343
+ var OpenAiClient = class {
2344
+ client;
2345
+ explicitApiKey;
2346
+ defaultModel;
2347
+ defaultMaxTokens;
2348
+ constructor(opts = {}) {
2349
+ this.client = opts.client ?? null;
2350
+ this.explicitApiKey = opts.apiKey;
2351
+ this.defaultModel = opts.defaultModel ?? DEFAULT_OPENAI_MODEL;
2352
+ this.defaultMaxTokens = opts.defaultMaxTokens ?? DEFAULT_MAX_TOKENS2;
2353
+ }
2354
+ resolveClient() {
2355
+ if (this.client !== null) return this.client;
2356
+ const apiKey = this.explicitApiKey ?? readOpenAiApiKey();
2357
+ if (apiKey === void 0 || apiKey.trim().length === 0) {
2358
+ throw new LlmError({
2359
+ code: "qaios.llm.api_key_missing",
2360
+ message: 'OPENAI_API_KEY is not set (llm.provider is "openai").\nGet one at https://platform.openai.com/api-keys, then:\n export OPENAI_API_KEY=sk-\u2026\n`qaios doctor` will confirm the key is reachable.'
2361
+ });
2362
+ }
2363
+ const sdk = new OpenAI({ apiKey });
2364
+ this.client = sdk;
2365
+ return this.client;
2366
+ }
2367
+ async call(opts) {
2368
+ const client = this.resolveClient();
2369
+ const model = opts.model ?? this.defaultModel;
2370
+ const params = {
2371
+ model,
2372
+ max_tokens: opts.maxTokens ?? this.defaultMaxTokens,
2373
+ // OpenAI carries the system prompt as a leading system-role message.
2374
+ messages: [
2375
+ { role: "system", content: opts.systemPrompt },
2376
+ { role: "user", content: opts.userPrompt }
2377
+ ]
2378
+ };
2379
+ if (opts.temperature !== void 0) params["temperature"] = opts.temperature;
2380
+ if (opts.tools && opts.tools.length > 0) {
2381
+ params["tools"] = opts.tools.map((t) => ({
2382
+ type: "function",
2383
+ function: {
2384
+ name: t.name,
2385
+ description: t.description,
2386
+ parameters: openaiStrictify(t.input_schema),
2387
+ strict: true
2388
+ }
2389
+ }));
2390
+ }
2391
+ if (opts.toolChoice !== void 0) {
2392
+ params["tool_choice"] = mapToolChoice(opts.toolChoice);
2393
+ }
2394
+ const reqOpts = {};
2395
+ if (opts.signal !== void 0) reqOpts.signal = opts.signal;
2396
+ const start = Date.now();
2397
+ const response = await client.chat.completions.create(params, reqOpts);
2398
+ const latencyMs = Date.now() - start;
2399
+ return mapResponse2(response, latencyMs);
2400
+ }
2401
+ };
2402
+ function mapToolChoice(choice) {
2403
+ switch (choice.type) {
2404
+ case "auto":
2405
+ return "auto";
2406
+ case "any":
2407
+ return "required";
2408
+ case "tool":
2409
+ return { type: "function", function: { name: choice.name } };
2410
+ }
2411
+ }
2412
+ function mapFinishReason(reason) {
2413
+ switch (reason) {
2414
+ case "stop":
2415
+ return "end_turn";
2416
+ case "tool_calls":
2417
+ case "function_call":
2418
+ return "tool_use";
2419
+ case "length":
2420
+ return "max_tokens";
2421
+ default:
2422
+ return reason;
2423
+ }
2424
+ }
2425
+ function mapResponse2(response, latencyMs) {
2426
+ const choice = response.choices[0];
2427
+ const message = choice?.message;
2428
+ const output = message?.content ?? "";
2429
+ const toolCalls = (message?.tool_calls ?? []).map((tc) => ({
2430
+ id: tc.id,
2431
+ name: tc.function.name,
2432
+ // OpenAI returns function arguments as a JSON STRING — parse to match the
2433
+ // parsed-object shape Anthropic's tool_use blocks give us. A malformed
2434
+ // payload surfaces as an LlmError rather than a silent {}.
2435
+ input: parseArguments(tc.function.arguments, tc.function.name)
2436
+ }));
2437
+ const promptTokens = response.usage?.prompt_tokens ?? 0;
2438
+ const cachedTokens = response.usage?.prompt_tokens_details?.cached_tokens ?? 0;
2439
+ const usage = {
2440
+ // OpenAI's prompt_tokens INCLUDES cached tokens; bill the uncached portion
2441
+ // at the input rate and the cached portion at the cache-read rate.
2442
+ inputTokens: Math.max(0, promptTokens - cachedTokens),
2443
+ outputTokens: response.usage?.completion_tokens ?? 0
2444
+ };
2445
+ if (cachedTokens > 0) usage.cacheReadTokens = cachedTokens;
2446
+ return {
2447
+ output,
2448
+ toolCalls,
2449
+ usage,
2450
+ model: response.model,
2451
+ latencyMs,
2452
+ costUsdCents: computeCostUsdCents(response.model, usage),
2453
+ stopReason: mapFinishReason(choice?.finish_reason ?? null)
2454
+ };
2455
+ }
2456
+ function parseArguments(raw, toolName) {
2457
+ let parsed;
2458
+ try {
2459
+ parsed = JSON.parse(raw);
2460
+ } catch (err) {
2461
+ throw new LlmError({
2462
+ code: "qaios.llm.malformed_tool_arguments",
2463
+ message: `OpenAI returned non-JSON arguments for tool "${toolName}".`,
2464
+ cause: err
2465
+ });
2466
+ }
2467
+ return stripNulls(parsed);
2468
+ }
2469
+ function stripNulls(value) {
2470
+ if (Array.isArray(value)) return value.map(stripNulls);
2471
+ if (value !== null && typeof value === "object") {
2472
+ const out = {};
2473
+ for (const [k, v] of Object.entries(value)) {
2474
+ if (v === null) continue;
2475
+ out[k] = stripNulls(v);
2476
+ }
2477
+ return out;
2478
+ }
2479
+ return value;
2480
+ }
2481
+ function defaultModelFor(provider) {
2482
+ return provider === "openai" ? DEFAULT_OPENAI_MODEL : DEFAULT_LLM_MODEL;
2483
+ }
2484
+ function createLlmClient(opts = {}) {
2485
+ if (opts.client !== void 0) return opts.client;
2486
+ const provider = opts.provider ?? "anthropic";
2487
+ const defaultModel = opts.model ?? defaultModelFor(provider);
2488
+ const clientOpts = opts.apiKey !== void 0 ? { apiKey: opts.apiKey, defaultModel } : { defaultModel };
2489
+ return provider === "openai" ? new OpenAiClient(clientOpts) : new LlmClient(clientOpts);
2490
+ }
2180
2491
  var SkillError = class extends Error {
2181
2492
  code;
2182
2493
  skillId;
@@ -2708,13 +3019,14 @@ function resolveSpawn(bin, args) {
2708
3019
  }
2709
3020
  return { bin: resolved, args };
2710
3021
  }
3022
+ var SUPPRESSED_WARNING_CODES = /* @__PURE__ */ new Set(["DEP0190", "DEP0040"]);
2711
3023
  var _filterInstalled = false;
2712
3024
  function installDeprecationWarningFilter() {
2713
3025
  if (_filterInstalled) return;
2714
3026
  _filterInstalled = true;
2715
3027
  process.removeAllListeners("warning");
2716
3028
  process.on("warning", (w) => {
2717
- if (w.code === "DEP0190") return;
3029
+ if (w.code !== void 0 && SUPPRESSED_WARNING_CODES.has(w.code)) return;
2718
3030
  process.stderr.write(`(node:${process.pid}) ${w.name}: ${w.message}
2719
3031
  `);
2720
3032
  if (w.stack !== void 0) process.stderr.write(`${w.stack}
@@ -6020,7 +6332,7 @@ function parseOpenApi(specPath) {
6020
6332
  function isRecord(v) {
6021
6333
  return typeof v === "object" && v !== null && !Array.isArray(v);
6022
6334
  }
6023
- function resolveRef(refValue, doc, depth = 0) {
6335
+ function resolveRef2(refValue, doc, depth = 0) {
6024
6336
  if (depth > 4) return { $ref: refValue };
6025
6337
  if (!refValue.startsWith("#/")) return { $ref: refValue };
6026
6338
  const segments = refValue.slice(2).split("/").map((seg) => seg.replace(/~1/g, "/").replace(/~0/g, "~"));
@@ -6031,7 +6343,7 @@ function resolveRef(refValue, doc, depth = 0) {
6031
6343
  if (cursor === void 0) return { $ref: refValue };
6032
6344
  }
6033
6345
  if (isRecord(cursor) && typeof cursor["$ref"] === "string") {
6034
- return resolveRef(cursor["$ref"], doc, depth + 1);
6346
+ return resolveRef2(cursor["$ref"], doc, depth + 1);
6035
6347
  }
6036
6348
  return cursor;
6037
6349
  }
@@ -6040,7 +6352,7 @@ function resolveRefsDeep(value, doc, depth = 0) {
6040
6352
  if (Array.isArray(value)) return value.map((v) => resolveRefsDeep(v, doc, depth + 1));
6041
6353
  if (!isRecord(value)) return value;
6042
6354
  if (typeof value["$ref"] === "string") {
6043
- const resolved = resolveRef(value["$ref"], doc);
6355
+ const resolved = resolveRef2(value["$ref"], doc);
6044
6356
  return resolveRefsDeep(resolved, doc, depth + 1);
6045
6357
  }
6046
6358
  const out = {};
@@ -6498,7 +6810,7 @@ function runDoctor(opts = {}) {
6498
6810
  const cwd = path12.resolve(opts.cwd ?? process.cwd());
6499
6811
  const checks = [];
6500
6812
  checks.push(checkNode());
6501
- const apiKeyCheck = checkAnthropicApiKey();
6813
+ const apiKeyCheck = checkProviderApiKey(cwd);
6502
6814
  checks.push(apiKeyCheck);
6503
6815
  const qaiosDir = path12.join(cwd, ".qaios");
6504
6816
  const qaiosExists = existsSync(qaiosDir) && safeIsDir(qaiosDir);
@@ -6567,23 +6879,42 @@ function checkNode() {
6567
6879
  detail: `Node ${version} is below the required v${MIN_NODE_MAJOR}.`
6568
6880
  };
6569
6881
  }
6570
- function checkAnthropicApiKey() {
6571
- const key = readAnthropicApiKey();
6882
+ function resolveProviderFromConfig(cwd) {
6883
+ const candidate = path12.join(cwd, ".qaios", "config.yaml");
6884
+ if (!existsSync(candidate)) return { provider: "anthropic" };
6885
+ try {
6886
+ const raw = parse(readFileSync(candidate, "utf-8"));
6887
+ const parsed = QaiosConfig.safeParse(raw ?? { version: 1 });
6888
+ if (parsed.success) {
6889
+ const out = {
6890
+ provider: parsed.data.llm.provider
6891
+ };
6892
+ if (parsed.data.llm.apiKeyEnv !== void 0) out.apiKeyEnv = parsed.data.llm.apiKeyEnv;
6893
+ return out;
6894
+ }
6895
+ } catch {
6896
+ }
6897
+ return { provider: "anthropic" };
6898
+ }
6899
+ function checkProviderApiKey(cwd) {
6900
+ const { provider, apiKeyEnv } = resolveProviderFromConfig(cwd);
6901
+ const envVar = apiKeyEnv ?? (provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY");
6902
+ const key = readProviderApiKey(provider, apiKeyEnv);
6572
6903
  if (key !== void 0) {
6573
- return { name: "ANTHROPIC_API_KEY", status: "ok", detail: "set in environment" };
6904
+ return { name: envVar, status: "ok", detail: `set in environment (provider: ${provider})` };
6574
6905
  }
6575
- const raw = process.env["ANTHROPIC_API_KEY"];
6906
+ const raw = process.env[envVar];
6576
6907
  if (typeof raw === "string" && raw.length > 0) {
6577
6908
  return {
6578
- name: "ANTHROPIC_API_KEY",
6909
+ name: envVar,
6579
6910
  status: "warn",
6580
6911
  detail: "set but blank/whitespace-only \u2014 LLM commands will fail; export a real key"
6581
6912
  };
6582
6913
  }
6583
6914
  return {
6584
- name: "ANTHROPIC_API_KEY",
6915
+ name: envVar,
6585
6916
  status: "warn",
6586
- detail: "not set; export it before running `qaios test` or other LLM-backed commands"
6917
+ detail: `not set; export it before running LLM-backed commands (provider: ${provider})`
6587
6918
  };
6588
6919
  }
6589
6920
  function checkDb(dbPath) {
@@ -6873,12 +7204,20 @@ Use formal techniques (same enumeration as design.web). Prefer:
6873
7204
  Rules:
6874
7205
  - steps describe HTTP interactions: "POST /api/v1/users with body {email, password}",
6875
7206
  not implementation details.
6876
- - oracles describe response shape + status code + observable side effects. The oracle
7207
+ - oracles describe response shape + status code + observable side effects. EVERY oracle
6877
7208
  MUST reference an HTTP status code (e.g. "200", "201", "401", "404") AND/OR a
6878
7209
  response.<property> path so the writer skill can produce a deterministic assertion.
7210
+ An oracle that names neither is invalid \u2014 do not emit it.
6879
7211
  - dataNeeds describe request body categories: "valid signup payload", "payload missing
6880
7212
  email", "payload with email > 254 chars".
6881
7213
  - For each endpoint, generate at minimum 3 scenarios; more if the endpoint is risk-tagged.
7214
+ - COVERAGE FLOOR (non-negotiable): across the suite you MUST include at least one
7215
+ scenario with testType="negative" (e.g. invalid auth / 4xx) AND at least one with
7216
+ testType="boundary" (e.g. a min/max field length or numeric edge). Suites missing
7217
+ either are incomplete.
7218
+ - Every requirement id provided in the input MUST be referenced by at least one
7219
+ scenario's requirementIds \u2014 do not leave a stated requirement uncovered, and do not
7220
+ cite a requirement id that wasn't given.
6882
7221
  - Cross-endpoint dependency tests (e.g., create then read) get their own scenario with
6883
7222
  testType=integration.
6884
7223
 
@@ -6953,19 +7292,26 @@ function checkAuthScenarios(output, endpoints) {
6953
7292
  const authNeeded = endpoints.filter((e) => e.authRequired);
6954
7293
  if (authNeeded.length === 0) return 1;
6955
7294
  const scenarios = output.designSpec.scenarios;
6956
- const covered = authNeeded.filter((ep) => {
7295
+ const isAuthNegative = (s) => {
7296
+ if (s.testType !== "negative") return false;
7297
+ const oracleMentionsAuthCode = /\b401\b|\b403\b|unauthor|forbidden/i.test(s.oracle);
7298
+ const dataNeedsMentionsAuth = s.dataNeeds.some(
7299
+ (d) => /\b(missing|invalid|no|expired|wrong)\b.*(token|auth|key|credential|role)/i.test(d)
7300
+ );
7301
+ return oracleMentionsAuthCode || dataNeedsMentionsAuth;
7302
+ };
7303
+ const anyAuthTest = authNeeded.some((ep) => {
6957
7304
  const re = endpointStepPattern(ep.path, ep.method);
6958
- return scenarios.some((s) => {
6959
- const stepBlob = s.steps.join("\n");
6960
- const matchesEndpoint = re.test(stepBlob);
6961
- const oracleMentions401 = /\b401\b|unauthor/i.test(s.oracle);
6962
- const dataNeedsMentionsAuth = s.dataNeeds.some(
6963
- (d) => /\b(missing|invalid|no|expired)\b.*(token|auth|key|credential)/i.test(d)
6964
- );
6965
- return s.testType === "negative" && matchesEndpoint && (oracleMentions401 || dataNeedsMentionsAuth);
6966
- });
7305
+ return scenarios.some((s) => isAuthNegative(s) && re.test(s.steps.join("\n")));
7306
+ });
7307
+ const hasGenericAuthTest = scenarios.some(isAuthNegative);
7308
+ if (!anyAuthTest && !hasGenericAuthTest) return 0.5;
7309
+ const explicitlyCovered = authNeeded.filter((ep) => {
7310
+ const re = endpointStepPattern(ep.path, ep.method);
7311
+ return scenarios.some((s) => isAuthNegative(s) && re.test(s.steps.join("\n")));
6967
7312
  }).length;
6968
- return covered / authNeeded.length;
7313
+ const fraction = explicitlyCovered / authNeeded.length;
7314
+ return Math.max(0.85, fraction);
6969
7315
  }
6970
7316
  var designApiSkill = {
6971
7317
  id: "design.api",
@@ -8202,6 +8548,15 @@ var skills = {
8202
8548
  "audit.a11y": auditA11ySkill
8203
8549
  };
8204
8550
 
8551
+ // src/llm.ts
8552
+ function resolveLlmClient(injected, llmConfig) {
8553
+ if (injected !== void 0) return injected;
8554
+ const opts = {};
8555
+ if (llmConfig?.provider !== void 0) opts.provider = llmConfig.provider;
8556
+ if (llmConfig?.model !== void 0) opts.model = llmConfig.model;
8557
+ return createLlmClient(opts);
8558
+ }
8559
+
8205
8560
  // src/commands/a11y.ts
8206
8561
  function loadConfig(cwd) {
8207
8562
  const candidate = path12.join(cwd, ".qaios", "config.yaml");
@@ -8242,7 +8597,7 @@ async function runA11y(opts) {
8242
8597
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
8243
8598
  const auditLogger = new AuditLogger(storage.db);
8244
8599
  const workflowsRepo = new WorkflowsRepository(storage.db);
8245
- const llm = opts.llm ?? new LlmClient();
8600
+ const llm = resolveLlmClient(opts.llm, config?.llm);
8246
8601
  const writeOut = (line) => {
8247
8602
  if (opts.quiet === true) return;
8248
8603
  if (opts.json === true) process.stdout.write(JSON.stringify({ kind: "log", line }) + "\n");
@@ -8547,6 +8902,18 @@ async function runExplore(opts) {
8547
8902
  }
8548
8903
  };
8549
8904
  }
8905
+ if (opts.duration !== void 0) {
8906
+ const d = opts.duration;
8907
+ if (!Number.isFinite(d) || !Number.isInteger(d) || d < 60) {
8908
+ return {
8909
+ exitCode: ExitCode.USER_ERROR,
8910
+ error: {
8911
+ code: "qaios.explore.invalid_duration",
8912
+ message: `--duration must be a whole number of seconds \u2265 60 (got ${String(d)}).`
8913
+ }
8914
+ };
8915
+ }
8916
+ }
8550
8917
  const config = loadConfig2(cwd);
8551
8918
  const memory = loadProjectMemory(cwd);
8552
8919
  const mode = opts.mode ?? config?.mode ?? "LITE";
@@ -8554,7 +8921,7 @@ async function runExplore(opts) {
8554
8921
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
8555
8922
  const auditLogger = new AuditLogger(storage.db);
8556
8923
  const workflowsRepo = new WorkflowsRepository(storage.db);
8557
- const llm = opts.llm ?? new LlmClient();
8924
+ const llm = resolveLlmClient(opts.llm, config?.llm);
8558
8925
  const writeOut = (line) => {
8559
8926
  if (opts.quiet === true) return;
8560
8927
  if (opts.json === true) {
@@ -8965,7 +9332,7 @@ async function runFix(opts) {
8965
9332
  const testResultsRepo = new TestResultsRepository(storage.db);
8966
9333
  const workflowsRepo = new WorkflowsRepository(storage.db);
8967
9334
  const config = loadConfig3(cwd);
8968
- const llm = opts.llm ?? new LlmClient();
9335
+ const llm = resolveLlmClient(opts.llm, config?.llm);
8969
9336
  const mode = opts.mode ?? config?.mode ?? "LITE";
8970
9337
  const writeOut = (line) => {
8971
9338
  if (opts.quiet === true) return;
@@ -9594,8 +9961,6 @@ function runInit(opts = {}) {
9594
9961
  if (existsSync(qaiosDir) && opts.force) {
9595
9962
  rmSync(qaiosDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
9596
9963
  }
9597
- mkdirSync(qaiosDir, { recursive: true });
9598
- const filesWritten = [];
9599
9964
  const modeParse = Mode.safeParse(opts.mode ?? "LITE");
9600
9965
  if (!modeParse.success) {
9601
9966
  return {
@@ -9614,9 +9979,7 @@ function runInit(opts = {}) {
9614
9979
  testDir: opts.testDir ?? detection.testDir ?? "tests"
9615
9980
  }
9616
9981
  });
9617
- const configPath = path12.join(qaiosDir, "config.yaml");
9618
- writeFileSync(configPath, stringify(config), "utf-8");
9619
- filesWritten.push(path12.relative(cwd, configPath));
9982
+ mkdirSync(qaiosDir, { recursive: true });
9620
9983
  const dbPath = path12.join(qaiosDir, "workflows.db");
9621
9984
  let migrations;
9622
9985
  try {
@@ -9624,16 +9987,33 @@ function runInit(opts = {}) {
9624
9987
  migrations = storage.runMigrations();
9625
9988
  storage.close();
9626
9989
  } catch (err) {
9990
+ try {
9991
+ rmSync(qaiosDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
9992
+ } catch {
9993
+ }
9994
+ const raw = err.message ?? String(err);
9995
+ const isBindingError = /bindings file|was compiled against|NODE_MODULE_VERSION|\.node/i.test(
9996
+ raw
9997
+ );
9998
+ const message = isBindingError ? `Could not load the native SQLite module (better-sqlite3). This usually means the prebuilt binary didn't download or doesn't match your Node version.
9999
+ \u2022 Ensure you're on Node 20 LTS (run \`node -v\`).
10000
+ \u2022 Reinstall to fetch/rebuild the binary: \`npm rebuild better-sqlite3\` (or reinstall qaios).
10001
+ \u2022 Behind a proxy/firewall? The binary is fetched from GitHub releases \u2014 allow that, or install build tools so it can compile.
10002
+ Original error: ${raw}` : `Failed to initialise workflows.db: ${raw}`;
9627
10003
  return {
9628
- exitCode: ExitCode.INTERNAL,
10004
+ exitCode: err instanceof StorageError ? ExitCode.INTERNAL : ExitCode.TOOL_ERROR,
9629
10005
  error: {
9630
- code: err instanceof StorageError ? err.code : "qaios.init.db_failed",
9631
- message: `Failed to initialise workflows.db: ${err.message}`
10006
+ code: isBindingError ? "qaios.init.sqlite_binding_missing" : err instanceof StorageError ? err.code : "qaios.init.db_failed",
10007
+ message
9632
10008
  },
9633
10009
  detection
9634
10010
  };
9635
10011
  }
10012
+ const filesWritten = [];
9636
10013
  filesWritten.push(path12.relative(cwd, dbPath));
10014
+ const configPath = path12.join(qaiosDir, "config.yaml");
10015
+ writeFileSync(configPath, stringify(config), "utf-8");
10016
+ filesWritten.push(path12.relative(cwd, configPath));
9637
10017
  const gitignorePath = path12.join(qaiosDir, ".gitignore");
9638
10018
  writeFileSync(gitignorePath, QAIOS_GITIGNORE, "utf-8");
9639
10019
  filesWritten.push(path12.relative(cwd, gitignorePath));
@@ -9732,7 +10112,7 @@ function readRawConfig(configPath) {
9732
10112
  }
9733
10113
  return parsed;
9734
10114
  }
9735
- function readKey(obj, key) {
10115
+ function readKey2(obj, key) {
9736
10116
  const segments = key.split(".");
9737
10117
  let cursor = obj;
9738
10118
  for (const seg of segments) {
@@ -9823,7 +10203,7 @@ function getValue(configPath, opts, writeOut) {
9823
10203
  }
9824
10204
  return { exitCode: ExitCode.SUCCESS, value: raw };
9825
10205
  }
9826
- const value = readKey(raw, opts.key);
10206
+ const value = readKey2(raw, opts.key);
9827
10207
  if (value === void 0) {
9828
10208
  return {
9829
10209
  exitCode: ExitCode.USER_ERROR,
@@ -10214,6 +10594,16 @@ async function testServer(repo, opts, writeOut) {
10214
10594
  if (ownsClient) await client.close();
10215
10595
  }
10216
10596
  }
10597
+ function loadLlmConfig(cwd) {
10598
+ const candidate = path12.join(cwd, ".qaios", "config.yaml");
10599
+ if (!existsSync(candidate)) return void 0;
10600
+ try {
10601
+ const parsed = parse(readFileSync(candidate, "utf-8"));
10602
+ return parsed?.llm;
10603
+ } catch {
10604
+ return void 0;
10605
+ }
10606
+ }
10217
10607
  async function applyDecision(args) {
10218
10608
  const { gate, action, gatesRepo, auditLogger, orchestrator, skipResume } = args;
10219
10609
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -10274,7 +10664,7 @@ async function runReview(opts) {
10274
10664
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
10275
10665
  const auditLogger = new AuditLogger(storage.db);
10276
10666
  const gatesRepo = new GatesRepository(storage.db);
10277
- const llm = opts.llm ?? new LlmClient();
10667
+ const llm = resolveLlmClient(opts.llm, loadLlmConfig(cwd));
10278
10668
  try {
10279
10669
  const pending = gatesRepo.listPending(opts.workflowId);
10280
10670
  if (pending.length === 0) {
@@ -10499,8 +10889,8 @@ async function runRun(opts) {
10499
10889
  const ownsStorage = opts.storage === void 0;
10500
10890
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
10501
10891
  const auditLogger = new AuditLogger(storage.db);
10502
- const llm = opts.llm ?? new LlmClient();
10503
10892
  const config = loadRunConfig(cwd);
10893
+ const llm = resolveLlmClient(opts.llm, config?.llm);
10504
10894
  const args = {
10505
10895
  cwd,
10506
10896
  noClassify: opts.noClassify === true,
@@ -10799,7 +11189,7 @@ async function runSnapshotCheck(opts) {
10799
11189
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
10800
11190
  const auditLogger = new AuditLogger(storage.db);
10801
11191
  const baselineRepo = new VisualBaselinesRepository(storage.db);
10802
- const llm = opts.llm ?? new LlmClient();
11192
+ const llm = resolveLlmClient(opts.llm, config?.llm);
10803
11193
  let baselines = baselineRepo.list();
10804
11194
  if (opts.feature !== void 0) {
10805
11195
  const feat = opts.feature;
@@ -11375,7 +11765,7 @@ ${epSummary}`;
11375
11765
  const ownsStorage = opts.storage === void 0;
11376
11766
  const storage = opts.storage ?? Storage.open(path12.join(qaiosDir, "workflows.db"), { skipMigrations: false });
11377
11767
  const auditLogger = new AuditLogger(storage.db);
11378
- const llm = opts.llm ?? new LlmClient();
11768
+ const llm = resolveLlmClient(opts.llm, config?.llm);
11379
11769
  const gateConfig = {};
11380
11770
  if (opts.nonInteractive === true) gateConfig.nonInteractive = true;
11381
11771
  if (config?.gates?.autoExpireOnTimeout !== void 0) {
@@ -12015,13 +12405,19 @@ function formatDoctor(checks) {
12015
12405
  return lines.join("\n");
12016
12406
  }
12017
12407
  var invokedAsScript = (() => {
12018
- try {
12019
- const argv1 = process.argv[1];
12020
- if (!argv1) return false;
12021
- return path12.resolve(argv1) === fileURLToPath(import.meta.url);
12022
- } catch {
12023
- return false;
12024
- }
12408
+ const argv1 = process.argv[1];
12409
+ if (!argv1) return false;
12410
+ const thisFile = fileURLToPath(import.meta.url);
12411
+ const realOf = (p) => {
12412
+ try {
12413
+ return realpathSync(p);
12414
+ } catch {
12415
+ return path12.resolve(p);
12416
+ }
12417
+ };
12418
+ if (realOf(argv1) === realOf(thisFile)) return true;
12419
+ const invokedName = path12.basename(argv1).replace(/\.(js|mjs|cjs)$/, "");
12420
+ return invokedName === "qaios";
12025
12421
  })();
12026
12422
  if (invokedAsScript) {
12027
12423
  installDeprecationWarningFilter();
@@ -18,6 +18,19 @@
18
18
  // used unconditionally for stability — same input → same PNG → same SHA.
19
19
 
20
20
  import { writeFileSync } from 'node:fs';
21
+ import { createRequire } from 'node:module';
22
+ import path from 'node:path';
23
+ import { pathToFileURL } from 'node:url';
24
+
25
+ // Resolve `playwright` from the USER's project (process.cwd()), not from
26
+ // where this script lives in QAIOS's install dir. See axe-runner.mjs for
27
+ // the full rationale (a bare import resolves relative to this file, so a
28
+ // global QAIOS can't see the user's local playwright).
29
+ const projectRequire = createRequire(path.join(process.cwd(), 'package.json'));
30
+ const importFromProject = async (specifier) => {
31
+ const resolved = projectRequire.resolve(specifier);
32
+ return import(pathToFileURL(resolved).href);
33
+ };
21
34
 
22
35
  const url = process.env.QAIOS_SCREENSHOT_URL;
23
36
  const out = process.env.QAIOS_SCREENSHOT_OUTPUT;
@@ -37,7 +50,9 @@ const fullPage = process.env.QAIOS_SCREENSHOT_FULL_PAGE !== '0';
37
50
 
38
51
  let chromium;
39
52
  try {
40
- ({ chromium } = await import('playwright'));
53
+ const pw = await importFromProject('playwright');
54
+ chromium = pw.chromium ?? pw.default?.chromium;
55
+ if (!chromium) throw new Error('playwright resolved but has no chromium export');
41
56
  } catch (err) {
42
57
  process.stderr.write(
43
58
  `capture-screenshot: failed to load 'playwright' from project — is it installed? ${err?.message ?? err}\n`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qatonic_innovations/qaios",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "AI QA engineer in your terminal — designs, writes, runs, heals, and explores tests for web UI and APIs with audit-grade traceability.",
6
6
  "license": "MIT",
@@ -48,6 +48,7 @@
48
48
  "@modelcontextprotocol/sdk": "^1.29.0",
49
49
  "better-sqlite3": "^11.7.0",
50
50
  "commander": "^12.1.0",
51
+ "openai": "^4.77.0",
51
52
  "ink": "^5.2.1",
52
53
  "pino": "^9.5.0",
53
54
  "pino-pretty": "^11.3.0",
@@ -65,10 +66,7 @@
65
66
  "@types/pixelmatch": "^5.2.6",
66
67
  "@types/pngjs": "^6.0.5",
67
68
  "@types/react": "^18.3.28",
68
- "ink-testing-library": "^4.0.0",
69
- "@qaios/runtime": "0.0.0",
70
- "@qaios/shared": "0.0.0",
71
- "@qaios/skills": "0.0.0"
69
+ "ink-testing-library": "^4.0.0"
72
70
  },
73
71
  "scripts": {
74
72
  "build": "tsup && node scripts/copy-templates.mjs && node scripts/copy-runtime-assets.mjs",