qa-engineer 0.10.0 → 0.12.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.
Files changed (76) hide show
  1. package/README.md +167 -59
  2. package/package.json +1 -1
  3. package/packages/engine/bin/qa-engine.mjs +232 -8
  4. package/packages/engine/lib/analysis/har.mjs +34 -0
  5. package/packages/engine/lib/analysis/network.mjs +237 -0
  6. package/packages/engine/lib/analysis/report-html.mjs +47 -734
  7. package/packages/engine/lib/artifacts/manager.mjs +453 -0
  8. package/packages/engine/lib/artifacts/mime.mjs +109 -0
  9. package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
  10. package/packages/engine/lib/report/components/charts.mjs +424 -0
  11. package/packages/engine/lib/report/components/evidence.mjs +207 -0
  12. package/packages/engine/lib/report/components/findings.mjs +258 -0
  13. package/packages/engine/lib/report/components/nav.mjs +99 -0
  14. package/packages/engine/lib/report/components/primitives.mjs +246 -0
  15. package/packages/engine/lib/report/components/runtime.mjs +246 -0
  16. package/packages/engine/lib/report/components/timeline.mjs +65 -0
  17. package/packages/engine/lib/report/core/model.mjs +270 -0
  18. package/packages/engine/lib/report/core/normalize.mjs +226 -0
  19. package/packages/engine/lib/report/core/sections.mjs +978 -0
  20. package/packages/engine/lib/report/export/bundle.mjs +293 -0
  21. package/packages/engine/lib/report/export/html.mjs +183 -0
  22. package/packages/engine/lib/report/export/machine.mjs +290 -0
  23. package/packages/engine/lib/report/export/markdown.mjs +323 -0
  24. package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
  25. package/packages/engine/lib/report/theme/css.mjs +529 -0
  26. package/packages/engine/lib/report/theme/tokens.mjs +137 -0
  27. package/packages/engine/lib/report/version.mjs +78 -0
  28. package/packages/engine/package.json +2 -2
  29. package/packages/installer/lib/agents/targets.mjs +90 -0
  30. package/packages/installer/lib/agents/user-level.mjs +80 -0
  31. package/packages/installer/lib/cli/flags.mjs +20 -2
  32. package/packages/installer/lib/commands/doctor.mjs +6 -4
  33. package/packages/installer/lib/commands/install.mjs +134 -93
  34. package/packages/installer/lib/commands/repair.mjs +10 -6
  35. package/packages/installer/lib/commands/self-test.mjs +4 -3
  36. package/packages/installer/lib/commands/uninstall.mjs +14 -8
  37. package/packages/installer/lib/commands/update.mjs +9 -4
  38. package/packages/installer/lib/commands/verify.mjs +13 -12
  39. package/packages/installer/lib/constants.mjs +13 -0
  40. package/packages/installer/lib/core/conflict.mjs +5 -4
  41. package/packages/installer/lib/core/fs-safe.mjs +146 -6
  42. package/packages/installer/lib/core/integrity.mjs +59 -0
  43. package/packages/installer/lib/core/lockfile.mjs +19 -3
  44. package/packages/installer/lib/core/plan.mjs +213 -0
  45. package/packages/installer/lib/core/qa-home.mjs +145 -0
  46. package/packages/installer/lib/core/scope.mjs +274 -0
  47. package/packages/installer/lib/core/validate-install.mjs +37 -12
  48. package/packages/installer/package.json +1 -1
  49. package/packages/installer/schemas/qa-lock.schema.json +119 -21
  50. package/shared/tooling/qa-tool.mjs +41 -5
  51. package/skills/qa-api/scripts/qa-tool.mjs +41 -5
  52. package/skills/qa-audit/scripts/qa-tool.mjs +41 -5
  53. package/skills/qa-debug/SKILL.md +3 -0
  54. package/skills/qa-debug/references/failure-handoff.md +44 -0
  55. package/skills/qa-debug/scripts/qa-tool.mjs +41 -5
  56. package/skills/qa-explore/SKILL.md +26 -10
  57. package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
  58. package/skills/qa-explore/references/api-replay.md +40 -1
  59. package/skills/qa-explore/references/report-pipeline.md +265 -95
  60. package/skills/qa-explore/scripts/qa-tool.mjs +41 -5
  61. package/skills/qa-fix/scripts/qa-tool.mjs +41 -5
  62. package/skills/qa-flaky/scripts/qa-tool.mjs +41 -5
  63. package/skills/qa-init/scripts/qa-tool.mjs +41 -5
  64. package/skills/qa-report/scripts/qa-tool.mjs +41 -5
  65. package/skills/qa-run/README.md +7 -0
  66. package/skills/qa-run/SKILL.md +12 -7
  67. package/skills/qa-run/contracts/execution-result.schema.json +77 -0
  68. package/skills/qa-run/examples/execute-playwright.md +42 -10
  69. package/skills/qa-run/examples/plan-a-run.md +5 -3
  70. package/skills/qa-run/references/artifact-collector.md +2 -1
  71. package/skills/qa-run/references/command-builder.md +18 -1
  72. package/skills/qa-run/references/execution-strategy.md +2 -0
  73. package/skills/qa-run/references/failure-handoff.md +44 -0
  74. package/skills/qa-run/references/playwright-artifacts.md +3 -1
  75. package/skills/qa-run/references/playwright-execution.md +14 -1
  76. package/skills/qa-run/scripts/qa-tool.mjs +41 -5
@@ -0,0 +1,274 @@
1
+ // Installation scopes: where an install lives, and what it shares.
2
+ //
3
+ // Before this, there was one shape — "a project" — and every other arrangement was a
4
+ // user pointing `--project` somewhere unusual and hoping. Three arrangements are real,
5
+ // and each is now a named, tested mode rather than a trick:
6
+ //
7
+ // global one install per machine, in ~/.qa-engineer, reused by every project
8
+ // workspace one install per monorepo, at the repository root, reused by every package
9
+ // project one install in this project — the original behaviour, unchanged
10
+ //
11
+ // ## The one idea that makes sharing work
12
+ //
13
+ // A scope has a `qaRoot`: a directory holding exactly one copy of the engine and one
14
+ // canonical copy of the skills. Global puts it in the user's home; workspace puts it at
15
+ // the monorepo root; project has none, and keeps the engine bundled inside each skill
16
+ // the way it always has.
17
+ //
18
+ // The launcher (`shared/tooling/qa-tool.mjs`) walks up from wherever a skill happens to
19
+ // live looking for `.qa-engineer/engine`, then falls back to the machine home, then to
20
+ // node_modules, then to npx. So a skill does not need to know which scope installed it
21
+ // — which is precisely why one skill directory can serve all three.
22
+ //
23
+ // ## Why project mode is left alone
24
+ //
25
+ // Every existing install on disk is a project install. Changing what those look like
26
+ // would mean an upgrade that rewrites a user's repository, and `verify` reporting drift
27
+ // on installs that were fine. The safe direction here is obvious: new modes are added,
28
+ // the old one is not touched, and a 0.10 lockfile still verifies under 0.11.
29
+
30
+ import fs from 'node:fs';
31
+ import os from 'node:os';
32
+ import path from 'node:path';
33
+
34
+ import { LOCKFILE, QA_HOME_DIR_NAME } from '../constants.mjs';
35
+ import { qaHome } from './qa-home.mjs';
36
+ import { usageError } from './errors.mjs';
37
+
38
+ export const SCOPES = Object.freeze(['global', 'workspace', 'project']);
39
+
40
+ // Files that mark the root of a monorepo. Ordered by how strongly each implies "this is
41
+ // the top": a lockfile is decisive, a workspace manifest nearly so, `.git` last because
42
+ // a submodule has one too.
43
+ const WORKSPACE_MARKERS = Object.freeze([
44
+ { file: 'pnpm-workspace.yaml', kind: 'pnpm' },
45
+ { file: 'lerna.json', kind: 'lerna' },
46
+ { file: 'nx.json', kind: 'nx' },
47
+ { file: 'turbo.json', kind: 'turborepo' },
48
+ { file: 'rush.json', kind: 'rush' },
49
+ { file: 'go.work', kind: 'go' },
50
+ { file: 'Cargo.toml', kind: 'cargo', check: (text) => /^\s*\[workspace\]/m.test(text) },
51
+ ]);
52
+
53
+ /** True when this package.json declares npm/yarn workspaces. */
54
+ function declaresNpmWorkspaces(file) {
55
+ try {
56
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
57
+ return Array.isArray(parsed.workspaces) || typeof parsed.workspaces === 'object';
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Walk up from `start` looking for the root of a monorepo.
65
+ *
66
+ * Returns the *highest* match rather than the nearest: in a repository where a package
67
+ * has its own `package.json` and the root declares workspaces, the root is the answer.
68
+ * Stopping at the first hit would install once per package, which is the duplication
69
+ * this mode exists to remove.
70
+ */
71
+ export function findWorkspaceRoot(start = process.cwd(), { limit = 24 } = {}) {
72
+ let dir = path.resolve(start);
73
+ let best = null;
74
+
75
+ for (let depth = 0; depth < limit; depth += 1) {
76
+ for (const marker of WORKSPACE_MARKERS) {
77
+ const file = path.join(dir, marker.file);
78
+ if (!fs.existsSync(file)) continue;
79
+ if (marker.check) {
80
+ let text = '';
81
+ try {
82
+ text = fs.readFileSync(file, 'utf8');
83
+ } catch {
84
+ continue;
85
+ }
86
+ if (!marker.check(text)) continue;
87
+ }
88
+ best = { root: dir, kind: marker.kind, marker: marker.file };
89
+ }
90
+
91
+ const manifest = path.join(dir, 'package.json');
92
+ if (fs.existsSync(manifest) && declaresNpmWorkspaces(manifest)) {
93
+ best = { root: dir, kind: 'npm', marker: 'package.json' };
94
+ }
95
+
96
+ const parent = path.dirname(dir);
97
+ if (parent === dir) break;
98
+ dir = parent;
99
+ }
100
+
101
+ return best;
102
+ }
103
+
104
+ /**
105
+ * The user's home, where agent user-level directories live.
106
+ *
107
+ * `QA_ENGINEER_USER_HOME` exists for the test suite, which must not write into a real
108
+ * home to prove that a global install works. It is deliberately separate from
109
+ * `QA_ENGINEER_HOME`: that one moves what *we* own, this one moves where *agents* look,
110
+ * and a test needs to move both together while a user normally moves neither.
111
+ */
112
+ export function resolveUserHome({ env = process.env } = {}) {
113
+ const override = env.QA_ENGINEER_USER_HOME;
114
+ if (override && override.trim()) return path.resolve(override.trim());
115
+ return os.homedir();
116
+ }
117
+
118
+ /** The deepest directory containing both paths. */
119
+ function commonAncestor(a, b) {
120
+ const left = path.resolve(a).split(path.sep);
121
+ const right = path.resolve(b).split(path.sep);
122
+ const shared = [];
123
+ for (let i = 0; i < Math.min(left.length, right.length); i += 1) {
124
+ if (left[i] !== right[i]) break;
125
+ shared.push(left[i]);
126
+ }
127
+ return shared.join(path.sep) || path.parse(path.resolve(a)).root;
128
+ }
129
+
130
+ /** POSIX-style path from `root` to `target`, for lockfile entries. */
131
+ function toRelative(root, target) {
132
+ return path.relative(root, target).split(path.sep).join('/');
133
+ }
134
+
135
+ /**
136
+ * Resolve the scope a command should operate on.
137
+ *
138
+ * Exactly one mode may be requested. Defaulting silently to `project` when `--global`
139
+ * was misspelled would install into whatever directory the user happened to be in,
140
+ * which is the kind of surprise that costs trust once and permanently.
141
+ */
142
+ export function resolveScope({
143
+ global: wantGlobal = false,
144
+ workspace = false,
145
+ project = null,
146
+ cwd = process.cwd(),
147
+ env = process.env,
148
+ } = {}) {
149
+ const requested = [wantGlobal && 'global', workspace && 'workspace', project && 'project'].filter(Boolean);
150
+ if (requested.length > 1) {
151
+ throw usageError(
152
+ `choose one installation scope, not ${requested.length}: ${requested.map((r) => `--${r}`).join(' and ')}`,
153
+ );
154
+ }
155
+
156
+ if (wantGlobal) {
157
+ // Two directories are in play and they are not the same one. Everything the tool
158
+ // owns goes in `qaRoot`; the links that make agents see it go in *their* user-level
159
+ // directories, which live in the user's home. Conflating the two put `.claude/` and
160
+ // `.gemini/` inside `~/.qa-engineer`, where no agent looks.
161
+ const qaRoot = qaHome({ env });
162
+ const userHome = resolveUserHome({ env });
163
+ const root = commonAncestor(qaRoot, userHome);
164
+
165
+ if (root === path.parse(root).root) {
166
+ throw usageError(
167
+ `${qaRoot} and ${userHome} share no directory but the filesystem root, so a single ` +
168
+ 'install cannot contain both',
169
+ 'point QA_ENGINEER_HOME somewhere inside your home directory',
170
+ );
171
+ }
172
+
173
+ const qaRootRelative = toRelative(root, qaRoot);
174
+ return Object.freeze({
175
+ kind: 'global',
176
+ // The transaction is confined here, and it contains both the owned directory and
177
+ // the agent directories being linked into.
178
+ root,
179
+ qaRoot,
180
+ qaRootRelative,
181
+ userHome,
182
+ lockfile: path.posix.join(qaRootRelative, LOCKFILE),
183
+ shareEngine: true,
184
+ userLevel: true,
185
+ label: `global (${qaRoot})`,
186
+ describe: () => `machine-wide install in ${qaRoot}`,
187
+ });
188
+ }
189
+
190
+ if (workspace) {
191
+ const found = findWorkspaceRoot(cwd);
192
+ if (!found) {
193
+ throw usageError(
194
+ `no monorepo root found above ${path.resolve(cwd)} — looked for ` +
195
+ `${WORKSPACE_MARKERS.map((m) => m.file).join(', ')}, and package.json with a "workspaces" field`,
196
+ 'run from inside the monorepo, or use --project <dir> to name the root explicitly',
197
+ );
198
+ }
199
+ return Object.freeze({
200
+ kind: 'workspace',
201
+ root: found.root,
202
+ qaRoot: path.join(found.root, QA_HOME_DIR_NAME),
203
+ qaRootRelative: QA_HOME_DIR_NAME,
204
+ lockfile: LOCKFILE,
205
+ shareEngine: true,
206
+ userLevel: false,
207
+ workspaceKind: found.kind,
208
+ label: `workspace (${found.root}, detected by ${found.marker})`,
209
+ describe: () => `${found.kind} monorepo rooted at ${found.root}`,
210
+ });
211
+ }
212
+
213
+ const root = path.resolve(project ?? cwd);
214
+ return Object.freeze({
215
+ kind: 'project',
216
+ root,
217
+ // No shared root: the engine travels inside each skill, as it always has.
218
+ qaRoot: null,
219
+ qaRootRelative: null,
220
+ lockfile: LOCKFILE,
221
+ shareEngine: false,
222
+ userLevel: false,
223
+ label: `project (${root})`,
224
+ describe: () => `project install in ${root}`,
225
+ });
226
+ }
227
+
228
+ /**
229
+ * The scope an already-installed tree belongs to, for commands that operate on an
230
+ * existing install rather than creating one.
231
+ *
232
+ * A lockfile records its own scope from 0.11 onward. One written by an earlier version
233
+ * has no `scope` field and is a project install by definition, because that is the only
234
+ * kind that existed — so the absence is read as `project` rather than as corruption.
235
+ */
236
+ export function scopeOfLock(lock) {
237
+ return lock?.scope?.kind ?? 'project';
238
+ }
239
+
240
+ /**
241
+ * The scope a lifecycle command should act on when the user did not name one.
242
+ *
243
+ * `qa verify` with no flags should verify the install that is actually in play, and
244
+ * which one that is depends on where you are standing. A project with its own lockfile
245
+ * is the answer there; otherwise the machine-wide install is, because that is what is
246
+ * serving this project. Making the user pass `--global` to check the only install they
247
+ * have would be a tool asking a question it can answer itself.
248
+ *
249
+ * Project beats global deliberately, matching every layered tool a developer already
250
+ * knows: a local `node_modules` beats a global one, `.git/config` beats `~/.gitconfig`.
251
+ */
252
+ export function resolveOperatingScope({
253
+ global: wantGlobal = false,
254
+ workspace = false,
255
+ project = null,
256
+ cwd = process.cwd(),
257
+ env = process.env,
258
+ } = {}) {
259
+ if (wantGlobal || workspace || project) {
260
+ return resolveScope({ global: wantGlobal, workspace, project, cwd, env });
261
+ }
262
+
263
+ const here = path.resolve(cwd);
264
+ if (fs.existsSync(path.join(here, LOCKFILE))) {
265
+ return resolveScope({ project: here, cwd, env });
266
+ }
267
+
268
+ const globalScope = resolveScope({ global: true, cwd, env });
269
+ if (fs.existsSync(path.join(globalScope.root, globalScope.lockfile))) return globalScope;
270
+
271
+ // Nothing installed anywhere reachable. Return the project scope so the command
272
+ // reports "no lockfile here", which is the honest and actionable message.
273
+ return resolveScope({ project: here, cwd, env });
274
+ }
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { LOCKFILE, SHARED_SKILLS_DIR, CLAUDE_SKILLS_DIR } from '../constants.mjs';
6
6
  import { readLock } from './lockfile.mjs';
7
- import { hashFile } from './hash.mjs';
7
+ import { entryDigest } from './integrity.mjs';
8
8
  import { verifyEngine, packHasBundles } from './bundle.mjs';
9
9
  import { BUNDLE_DEST, BUNDLE_MANIFEST } from './manifest.mjs';
10
10
 
@@ -17,17 +17,18 @@ import { BUNDLE_DEST, BUNDLE_MANIFEST } from './manifest.mjs';
17
17
  * @param {string} projectRoot
18
18
  * @returns {{ ok: boolean, checks: CheckResult[] }}
19
19
  */
20
- export function validateInstall(projectRoot) {
20
+ export function validateInstall(projectRoot, { scope = null } = {}) {
21
21
  /** @type {CheckResult[]} */
22
22
  const checks = [];
23
23
 
24
- const lock = readLock(projectRoot);
24
+ const lockRelative = scope?.lockfile ?? LOCKFILE;
25
+ const lock = readLock(projectRoot, lockRelative);
25
26
  if (!lock) {
26
27
  checks.push({
27
28
  id: 'lockfile',
28
29
  ok: false,
29
30
  hard: true,
30
- message: `${LOCKFILE} missing`,
31
+ message: `${lockRelative} missing`,
31
32
  hint: 'run: qa install',
32
33
  });
33
34
  return { ok: false, checks };
@@ -37,17 +38,12 @@ export function validateInstall(projectRoot) {
37
38
  id: 'lockfile',
38
39
  ok: true,
39
40
  hard: true,
40
- message: `${LOCKFILE} present (${lock.files.length} files, pack ${lock.pack?.version ?? '?'})`,
41
+ message: `${lockRelative} present (${lock.files.length} files, pack ${lock.pack?.version ?? '?'})`,
41
42
  });
42
43
 
43
44
  let drift = 0;
44
45
  for (const entry of lock.files) {
45
- const abs = path.join(projectRoot, entry.path);
46
- if (!fs.existsSync(abs)) {
47
- drift += 1;
48
- continue;
49
- }
50
- if (hashFile(abs) !== entry.sha256) drift += 1;
46
+ if (entryDigest(projectRoot, entry) !== entry.sha256) drift += 1;
51
47
  }
52
48
  checks.push({
53
49
  id: 'integrity',
@@ -92,7 +88,36 @@ export function validateInstall(projectRoot) {
92
88
  : 'no contract schemas found (optional for minimal installs)',
93
89
  });
94
90
 
95
- if (packHasBundles()) {
91
+ // Where the engine should be depends on how it was installed: a shared scope has
92
+ // exactly one copy in its qaRoot, a project install has one inside each bundling
93
+ // skill. Looking only for the second is what made the first report a broken install.
94
+ const sharedScope = scope?.shareEngine || lock.scope?.sharedEngine;
95
+ if (sharedScope) {
96
+ const qaRootRelative = scope?.qaRootRelative ?? lock.scope?.qaRoot ?? '.';
97
+ const libDir = path.join(projectRoot, qaRootRelative, 'engine');
98
+ const bundleOk = fs.existsSync(path.join(libDir, 'bin', 'qa-engine.mjs'));
99
+ checks.push({
100
+ id: 'engine',
101
+ ok: bundleOk,
102
+ hard: true,
103
+ message: bundleOk
104
+ ? `deterministic engine shared at ${path.relative(projectRoot, libDir) || '.'}`
105
+ : 'shared deterministic engine missing',
106
+ hint: bundleOk ? undefined : 'run: qa repair',
107
+ });
108
+ if (bundleOk) {
109
+ const result = verifyEngine({ libDir });
110
+ checks.push({
111
+ id: 'engine-runs',
112
+ ok: result.ok,
113
+ hard: true,
114
+ message: result.ok
115
+ ? `shared engine runs (node ${process.versions.node})`
116
+ : `shared engine failed to run: ${result.stderr || 'unknown error'}`,
117
+ hint: result.ok ? undefined : 'run: qa repair',
118
+ });
119
+ }
120
+ } else if (packHasBundles()) {
96
121
  const bundledSkill = Object.keys(BUNDLE_MANIFEST)[0];
97
122
  let libDir = null;
98
123
  for (const base of [SHARED_SKILLS_DIR, CLAUDE_SKILLS_DIR]) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qa-engineer/installer",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "The qa-engineer installer: agent detection, copy-based install, lockfile integrity, and wrapper generation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -5,7 +5,14 @@
5
5
  "description": "qa-lock.json records exactly what was installed and a hash per file, so an install can be verified, updated, or removed without guessing. Written by the installer; not hand-edited.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
- "required": ["lockfileVersion", "pack", "installer", "generatedAt", "agents", "files"],
8
+ "required": [
9
+ "lockfileVersion",
10
+ "pack",
11
+ "installer",
12
+ "generatedAt",
13
+ "agents",
14
+ "files"
15
+ ],
9
16
  "properties": {
10
17
  "lockfileVersion": {
11
18
  "type": "integer",
@@ -14,11 +21,22 @@
14
21
  "pack": {
15
22
  "type": "object",
16
23
  "additionalProperties": false,
17
- "required": ["name", "version", "specRevision"],
24
+ "required": [
25
+ "name",
26
+ "version",
27
+ "specRevision"
28
+ ],
18
29
  "properties": {
19
- "name": { "type": "string" },
20
- "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+" },
21
- "specRevision": { "type": "string" }
30
+ "name": {
31
+ "type": "string"
32
+ },
33
+ "version": {
34
+ "type": "string",
35
+ "pattern": "^\\d+\\.\\d+\\.\\d+"
36
+ },
37
+ "specRevision": {
38
+ "type": "string"
39
+ }
22
40
  }
23
41
  },
24
42
  "installer": {
@@ -35,17 +53,31 @@
35
53
  "items": {
36
54
  "type": "object",
37
55
  "additionalProperties": false,
38
- "required": ["id", "name", "tier"],
56
+ "required": [
57
+ "id",
58
+ "name",
59
+ "tier"
60
+ ],
39
61
  "properties": {
40
- "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
41
- "name": { "type": "string" },
62
+ "id": {
63
+ "type": "string",
64
+ "pattern": "^[a-z][a-z0-9-]*$"
65
+ },
66
+ "name": {
67
+ "type": "string"
68
+ },
42
69
  "tier": {
43
- "type": ["integer", "null"],
70
+ "type": [
71
+ "integer",
72
+ "null"
73
+ ],
44
74
  "minimum": 1,
45
75
  "maximum": 2,
46
- "description": "Support tier, or null when the host is unknown the shared Agent Skills path was installed without identifying a product."
76
+ "description": "Support tier, or null when the host is unknown \u2014 the shared Agent Skills path was installed without identifying a product."
77
+ },
78
+ "skillsDir": {
79
+ "type": "string"
47
80
  },
48
- "skillsDir": { "type": "string" },
49
81
  "detected": {
50
82
  "type": "boolean",
51
83
  "description": "True only when project markers identified this host. False when it was requested explicitly or stands in as the unknown-agent path. Optional for lockfiles written before this field existed."
@@ -59,7 +91,12 @@
59
91
  "items": {
60
92
  "type": "object",
61
93
  "additionalProperties": false,
62
- "required": ["path", "sha256", "bytes", "owner"],
94
+ "required": [
95
+ "path",
96
+ "sha256",
97
+ "bytes",
98
+ "owner"
99
+ ],
63
100
  "properties": {
64
101
  "path": {
65
102
  "type": "string",
@@ -67,11 +104,36 @@
67
104
  "description": "Project-relative POSIX path. Must not be absolute and must not contain a '..' segment: a lockfile travels with a cloned repository, and uninstall deletes what it lists.",
68
105
  "pattern": "^(?!/)(?!.*(^|/)\\.\\.($|/)).+$"
69
106
  },
70
- "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
71
- "bytes": { "type": "integer", "minimum": 0 },
72
- "owner": { "type": "string", "enum": ["skill", "wrapper", "config"] },
73
- "skill": { "type": "string" },
74
- "agent": { "type": "string" }
107
+ "sha256": {
108
+ "type": "string",
109
+ "pattern": "^[0-9a-f]{64}$"
110
+ },
111
+ "bytes": {
112
+ "type": "integer",
113
+ "minimum": 0
114
+ },
115
+ "owner": {
116
+ "type": "string",
117
+ "enum": [
118
+ "skill",
119
+ "wrapper",
120
+ "config",
121
+ "engine",
122
+ "link"
123
+ ],
124
+ "description": "What created the entry. `engine` is the shared deterministic engine, present once per scope. `link` is a symbolic link into the canonical skill tree, whose sha256 is the hash of its scope-relative target rather than of file content \u2014 a link has no content, and hashing the target is what lets verify catch one that has been repointed."
125
+ },
126
+ "skill": {
127
+ "type": "string"
128
+ },
129
+ "agent": {
130
+ "type": "string"
131
+ },
132
+ "linkTarget": {
133
+ "type": "string",
134
+ "minLength": 1,
135
+ "description": "Absolute path a `link` entry points at. Absolute because the link is created with it verbatim; the hashed form in sha256 is scope-relative so the lockfile stays machine-independent."
136
+ }
75
137
  }
76
138
  }
77
139
  },
@@ -79,10 +141,46 @@
79
141
  "type": "object",
80
142
  "additionalProperties": false,
81
143
  "properties": {
82
- "skills": { "type": "integer", "minimum": 0 },
83
- "files": { "type": "integer", "minimum": 0 },
84
- "wrappers": { "type": "integer", "minimum": 0 },
85
- "directories": { "type": "integer", "minimum": 0 }
144
+ "skills": {
145
+ "type": "integer",
146
+ "minimum": 0
147
+ },
148
+ "files": {
149
+ "type": "integer",
150
+ "minimum": 0
151
+ },
152
+ "wrappers": {
153
+ "type": "integer",
154
+ "minimum": 0
155
+ },
156
+ "directories": {
157
+ "type": "integer",
158
+ "minimum": 0
159
+ }
160
+ }
161
+ },
162
+ "scope": {
163
+ "type": "object",
164
+ "additionalProperties": false,
165
+ "required": [
166
+ "kind"
167
+ ],
168
+ "description": "Which installation mode wrote this lockfile. Absent means `project`: before 0.11 that was the only mode, so its absence is a fact rather than a gap.",
169
+ "properties": {
170
+ "kind": {
171
+ "enum": [
172
+ "global",
173
+ "workspace",
174
+ "project"
175
+ ]
176
+ },
177
+ "qaRoot": {
178
+ "type": "string",
179
+ "description": "Scope-relative directory holding the shared engine and canonical skills. Absent for a project install, which shares nothing."
180
+ },
181
+ "sharedEngine": {
182
+ "type": "boolean"
183
+ }
86
184
  }
87
185
  }
88
186
  }
@@ -28,6 +28,7 @@
28
28
  // `node qa-tool.mjs …`, which needs none.
29
29
 
30
30
  import fs from 'node:fs';
31
+ import os from 'node:os';
31
32
  import path from 'node:path';
32
33
  import { spawnSync } from 'node:child_process';
33
34
  import { fileURLToPath } from 'node:url';
@@ -37,7 +38,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
37
38
  const USAGE = `usage: node qa-tool.mjs <tool> <subcommand> [args]
38
39
 
39
40
  analysis parse artifacts, classify errors, validate contracts, diff-guard,
40
- read .qa/context.md, render an HTML report, print the footer
41
+ read .qa/context.md, render an HTML report, print the footer
41
42
  diagnostics root cause, timeline, priority, repair plans, release readiness
42
43
  playwright normalize a Playwright report or summarize a trace
43
44
 
@@ -52,15 +53,26 @@ examples:
52
53
  /**
53
54
  * Where the engine is, and how we found it.
54
55
  *
55
- * Ordered by cost: a bundled copy needs no resolution and no network, a
56
- * node_modules copy needs no network, and npx needs both on first use. Reporting
57
- * *which* one answered matters when a skill degrades — "the engine is missing" and
58
- * "the engine is being fetched" are different problems.
56
+ * Ordered by cost: a bundled copy needs no resolution and no network, a shared copy
57
+ * needs one stat per ancestor, a node_modules copy needs no network, and npx needs both
58
+ * on first use. Reporting *which* one answered matters when a skill degrades — "the
59
+ * engine is missing" and "the engine is being fetched" are different problems.
60
+ *
61
+ * The shared lookup is what lets one skill directory serve a project, a workspace, and a
62
+ * machine-wide install without knowing which installed it. Walking up for
63
+ * `.qa-engineer/engine` finds a workspace install from a skill inside the repository,
64
+ * and finds a global install from a skill linked into an agent's user-level directory —
65
+ * `~/.claude/skills/qa-explore` walks up to `~`, where `~/.qa-engineer/engine` is.
59
66
  */
60
67
  function resolveEngine() {
61
68
  const bundled = path.join(here, 'lib', 'bin', 'qa-engine.mjs');
62
69
  if (fs.existsSync(bundled)) return { kind: 'bundled', command: [process.execPath, bundled] };
63
70
 
71
+ for (const root of candidateSharedRoots()) {
72
+ const shared = path.join(root, 'bin', 'qa-engine.mjs');
73
+ if (fs.existsSync(shared)) return { kind: 'shared', command: [process.execPath, shared] };
74
+ }
75
+
64
76
  for (const base of candidateModuleRoots()) {
65
77
  const installed = path.join(base, 'qa-engineer', 'packages', 'engine', 'bin', 'qa-engine.mjs');
66
78
  if (fs.existsSync(installed)) {
@@ -74,6 +86,30 @@ function resolveEngine() {
74
86
  };
75
87
  }
76
88
 
89
+ /** Shared engine directories worth checking, most specific first. */
90
+ function candidateSharedRoots() {
91
+ const roots = [];
92
+
93
+ // An explicit home wins over anything discovered, so a user who moved the install can
94
+ // rely on it rather than on whatever the walk happens to find first.
95
+ const override = process.env.QA_ENGINEER_HOME;
96
+ if (override && override.trim()) roots.push(path.join(path.resolve(override.trim()), 'engine'));
97
+
98
+ let dir = here;
99
+ for (let depth = 0; depth < 12; depth += 1) {
100
+ roots.push(path.join(dir, '.qa-engineer', 'engine'));
101
+ const parent = path.dirname(dir);
102
+ if (parent === dir) break;
103
+ dir = parent;
104
+ }
105
+
106
+ // The default machine home, for the case where the skill lives outside it entirely.
107
+ const home = os.homedir();
108
+ if (home) roots.push(path.join(home, '.qa-engineer', 'engine'));
109
+
110
+ return roots;
111
+ }
112
+
77
113
  /** node_modules directories worth checking, nearest first. */
78
114
  function candidateModuleRoots() {
79
115
  const roots = [];