qa-engineer 0.9.2 → 0.11.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 (147) hide show
  1. package/COMPATIBILITY.md +26 -12
  2. package/README.md +182 -84
  3. package/package.json +11 -20
  4. package/packages/engine/bin/qa-engine.mjs +560 -0
  5. package/packages/engine/lib/analysis/branding.mjs +187 -0
  6. package/packages/engine/lib/analysis/context.mjs +294 -0
  7. package/packages/engine/lib/analysis/contracts.mjs +149 -0
  8. package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
  9. package/packages/engine/lib/analysis/discovery.mjs +220 -0
  10. package/packages/engine/lib/analysis/evidence.mjs +116 -0
  11. package/packages/engine/lib/analysis/har.mjs +124 -0
  12. package/packages/engine/lib/analysis/junit.mjs +126 -0
  13. package/packages/engine/lib/analysis/network.mjs +237 -0
  14. package/packages/engine/lib/analysis/redaction.mjs +127 -0
  15. package/packages/engine/lib/analysis/report-html.mjs +76 -0
  16. package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
  17. package/packages/engine/lib/analysis/xml.mjs +153 -0
  18. package/packages/engine/lib/analysis/zip.mjs +107 -0
  19. package/packages/engine/lib/artifacts/manager.mjs +453 -0
  20. package/packages/engine/lib/artifacts/mime.mjs +109 -0
  21. package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
  22. package/packages/engine/lib/diagnostics/engine.mjs +165 -0
  23. package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
  24. package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
  25. package/packages/engine/lib/diagnostics/repair.mjs +73 -0
  26. package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
  27. package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
  28. package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
  29. package/packages/engine/lib/frameworks/playwright.mjs +158 -0
  30. package/packages/engine/lib/report/components/charts.mjs +424 -0
  31. package/packages/engine/lib/report/components/evidence.mjs +207 -0
  32. package/packages/engine/lib/report/components/findings.mjs +258 -0
  33. package/packages/engine/lib/report/components/nav.mjs +99 -0
  34. package/packages/engine/lib/report/components/primitives.mjs +246 -0
  35. package/packages/engine/lib/report/components/runtime.mjs +246 -0
  36. package/packages/engine/lib/report/components/timeline.mjs +65 -0
  37. package/packages/engine/lib/report/core/model.mjs +270 -0
  38. package/packages/engine/lib/report/core/normalize.mjs +226 -0
  39. package/packages/engine/lib/report/core/sections.mjs +978 -0
  40. package/packages/engine/lib/report/export/bundle.mjs +293 -0
  41. package/packages/engine/lib/report/export/html.mjs +183 -0
  42. package/packages/engine/lib/report/export/machine.mjs +290 -0
  43. package/packages/engine/lib/report/export/markdown.mjs +323 -0
  44. package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
  45. package/packages/engine/lib/report/theme/css.mjs +529 -0
  46. package/packages/engine/lib/report/theme/tokens.mjs +137 -0
  47. package/packages/engine/lib/report/version.mjs +78 -0
  48. package/packages/engine/package.json +14 -0
  49. package/packages/installer/lib/agents/targets.mjs +90 -0
  50. package/packages/installer/lib/agents/user-level.mjs +80 -0
  51. package/packages/installer/lib/cli/flags.mjs +20 -2
  52. package/packages/installer/lib/commands/doctor.mjs +13 -20
  53. package/packages/installer/lib/commands/install.mjs +160 -91
  54. package/packages/installer/lib/commands/repair.mjs +10 -6
  55. package/packages/installer/lib/commands/self-test.mjs +4 -3
  56. package/packages/installer/lib/commands/uninstall.mjs +14 -8
  57. package/packages/installer/lib/commands/update.mjs +9 -4
  58. package/packages/installer/lib/commands/verify.mjs +13 -12
  59. package/packages/installer/lib/constants.mjs +13 -0
  60. package/packages/installer/lib/core/bundle.mjs +69 -92
  61. package/packages/installer/lib/core/conflict.mjs +5 -4
  62. package/packages/installer/lib/core/fs-safe.mjs +146 -6
  63. package/packages/installer/lib/core/integrity.mjs +59 -0
  64. package/packages/installer/lib/core/lockfile.mjs +19 -3
  65. package/packages/installer/lib/core/manifest.mjs +48 -57
  66. package/packages/installer/lib/core/plan.mjs +213 -0
  67. package/packages/installer/lib/core/qa-home.mjs +145 -0
  68. package/packages/installer/lib/core/scope.mjs +274 -0
  69. package/packages/installer/lib/core/validate-install.mjs +49 -31
  70. package/packages/installer/package.json +1 -1
  71. package/packages/installer/schemas/qa-lock.schema.json +119 -21
  72. package/shared/tooling/qa-tool.mjs +161 -0
  73. package/skills/qa-api/SKILL.md +5 -5
  74. package/skills/qa-api/references/deterministic-tooling.md +34 -32
  75. package/skills/qa-api/references/evidence-and-reporting.md +5 -5
  76. package/skills/qa-api/scripts/qa-tool.mjs +162 -0
  77. package/skills/qa-audit/SKILL.md +5 -5
  78. package/skills/qa-audit/references/deterministic-tooling.md +34 -32
  79. package/skills/qa-audit/references/evidence-and-reporting.md +5 -5
  80. package/skills/qa-audit/scripts/qa-tool.mjs +162 -0
  81. package/skills/qa-debug/SKILL.md +6 -6
  82. package/skills/qa-debug/references/deterministic-tooling.md +34 -32
  83. package/skills/qa-debug/references/diagnostic-engine.md +3 -3
  84. package/skills/qa-debug/references/evidence-and-reporting.md +5 -5
  85. package/skills/qa-debug/scripts/qa-tool.mjs +162 -0
  86. package/skills/qa-explore/SKILL.md +31 -15
  87. package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
  88. package/skills/qa-explore/references/api-replay.md +40 -1
  89. package/skills/qa-explore/references/deterministic-tooling.md +34 -32
  90. package/skills/qa-explore/references/evidence-and-reporting.md +5 -5
  91. package/skills/qa-explore/references/report-pipeline.md +266 -96
  92. package/skills/qa-explore/scripts/qa-tool.mjs +162 -0
  93. package/skills/qa-fix/SKILL.md +4 -4
  94. package/skills/qa-fix/references/deterministic-tooling.md +34 -32
  95. package/skills/qa-fix/references/diagnostic-engine.md +3 -3
  96. package/skills/qa-fix/references/evidence-and-reporting.md +5 -5
  97. package/skills/qa-fix/scripts/qa-tool.mjs +162 -0
  98. package/skills/qa-flaky/SKILL.md +4 -4
  99. package/skills/qa-flaky/references/deterministic-tooling.md +34 -32
  100. package/skills/qa-flaky/references/evidence-and-reporting.md +5 -5
  101. package/skills/qa-flaky/scripts/qa-tool.mjs +162 -0
  102. package/skills/qa-generate/references/evidence-and-reporting.md +5 -5
  103. package/skills/qa-init/SKILL.md +4 -4
  104. package/skills/qa-init/references/deterministic-tooling.md +34 -32
  105. package/skills/qa-init/references/evidence-and-reporting.md +5 -5
  106. package/skills/qa-init/scripts/qa-tool.mjs +162 -0
  107. package/skills/qa-report/SKILL.md +7 -7
  108. package/skills/qa-report/references/deterministic-tooling.md +34 -32
  109. package/skills/qa-report/references/diagnostic-engine.md +3 -3
  110. package/skills/qa-report/references/evidence-and-reporting.md +5 -5
  111. package/skills/qa-report/scripts/qa-tool.mjs +162 -0
  112. package/skills/qa-review/references/evidence-and-reporting.md +5 -5
  113. package/skills/qa-run/SKILL.md +6 -6
  114. package/skills/qa-run/references/deterministic-tooling.md +34 -32
  115. package/skills/qa-run/references/evidence-and-reporting.md +5 -5
  116. package/skills/qa-run/scripts/qa-tool.mjs +162 -0
  117. package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
  118. package/shared/analysis/lib/qa_analysis/branding.py +0 -175
  119. package/shared/analysis/lib/qa_analysis/cli.py +0 -144
  120. package/shared/analysis/lib/qa_analysis/context.py +0 -233
  121. package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
  122. package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
  123. package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
  124. package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
  125. package/shared/analysis/lib/qa_analysis/har.py +0 -87
  126. package/shared/analysis/lib/qa_analysis/junit.py +0 -104
  127. package/shared/analysis/lib/qa_analysis/redaction.py +0 -107
  128. package/shared/analysis/lib/qa_analysis/report_html.py +0 -781
  129. package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
  130. package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
  131. package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
  132. package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
  133. package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
  134. package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
  135. package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
  136. package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
  137. package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
  138. package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
  139. package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
  140. package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
  141. package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
  142. package/shared/tooling/qa_tool.py +0 -127
  143. /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
  144. /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
  145. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
  146. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
  147. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
@@ -4,18 +4,15 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { BACKUP_DIR, LOCKFILE, EXIT } from '../constants.mjs';
7
- import { resolveSourceRoot, resolveProjectRoot, toPosix } from '../core/paths.mjs';
8
- import { listSkills, skillFiles } from '../core/manifest.mjs';
9
- import { bundleFilesForSkill } from '../core/bundle.mjs';
10
- import { hashBytes } from '../core/hash.mjs';
7
+ import { resolveSourceRoot } from '../core/paths.mjs';
11
8
  import { detectConflicts } from '../core/conflict.mjs';
12
- import { Transaction } from '../core/fs-safe.mjs';
13
- import { buildLock, readLock, serializeLock, lockPath } from '../core/lockfile.mjs';
9
+ import { Transaction, readLinkTarget, canLink } from '../core/fs-safe.mjs';
10
+ import { buildLock, readLock, serializeLock, scopeLockPath } from '../core/lockfile.mjs';
11
+ import { resolveScope } from '../core/scope.mjs';
12
+ import { buildPlan, dedupePlan } from '../core/plan.mjs';
13
+ import { resolveScopeTargets, describeUnservedAgents } from '../agents/targets.mjs';
14
14
  import { loadConfig } from '../core/config.mjs';
15
- import { readSkillMeta } from '../core/skill-meta.mjs';
16
- import { renderWrapper } from '../core/wrappers.mjs';
17
15
  import { conflictError, verifyError } from '../core/errors.mjs';
18
- import { resolveInstallTargets } from '../agents/registry.mjs';
19
16
  import { createLogger } from '../core/logger.mjs';
20
17
  import { parseCommonFlags } from '../cli/flags.mjs';
21
18
  import { validateInstall } from '../core/validate-install.mjs';
@@ -92,6 +89,19 @@ function reportFrameworkFit(root, logger) {
92
89
  logger.info(' → unit tests only (Jest, Vitest, Jasmine, pytest)? That is expected');
93
90
  }
94
91
 
92
+ /**
93
+ * Name the hosts a global install could not serve, and what to do about them.
94
+ *
95
+ * Staying quiet here would leave a Cursor user waiting for slash commands that are never
96
+ * going to appear, with an install that reported success.
97
+ */
98
+ function reportUnserved(unserved, logger) {
99
+ if (!unserved || unserved.length === 0) return;
100
+ logger.info(` → not installed for ${unserved.map((entry) => entry.id).join(', ')}:`);
101
+ for (const entry of unserved) logger.info(` ${entry.id} — ${entry.reason}`);
102
+ logger.info(' → run `qa install --project .` inside a repository to serve those hosts');
103
+ }
104
+
95
105
  /**
96
106
  * Core install implementation shared by install / onboard / repair / update.
97
107
  *
@@ -108,7 +118,9 @@ function reportFrameworkFit(root, logger) {
108
118
  */
109
119
  export async function executeInstall({
110
120
  projectRoot,
121
+ scope: providedScope = null,
111
122
  agentIds = [],
123
+ allAgents = false,
112
124
  force = false,
113
125
  dryRun = false,
114
126
  skipValidate = false,
@@ -118,85 +130,58 @@ export async function executeInstall({
118
130
  } = {}) {
119
131
  const logger = log ?? createLogger();
120
132
  const sourceRoot = resolveSourceRoot();
121
- const root = resolveProjectRoot(projectRoot);
133
+ // A caller that names a scope gets it; everything else is a project install at the
134
+ // path it asked for, which is what every pre-0.11 caller means.
135
+ const scope = providedScope ?? resolveScope({ project: projectRoot ?? process.cwd() });
136
+ const root = scope.root;
122
137
  const { config } = loadConfig(root);
123
138
  const explicit = agentIds.length > 0 ? agentIds : config.agents ?? [];
124
- const agents = resolveInstallTargets(root, explicit);
125
- const skills = listSkills(sourceRoot);
139
+ const agents = resolveScopeTargets(scope, { explicitIds: explicit, allAgents });
140
+ const unserved = describeUnservedAgents(scope, agents);
141
+
142
+ if (agents.length === 0) {
143
+ throw conflictError(
144
+ `no agent can be served by a ${scope.kind} install here`,
145
+ scope.kind === 'global'
146
+ ? 'no supported host has a user-level skills directory on this machine; ' +
147
+ 'install per project with: qa install --project .'
148
+ : 'pass --agent <id> to name one explicitly',
149
+ );
150
+ }
151
+
152
+ // Probe once, before planning. A filesystem that cannot hold a directory link — FAT,
153
+ // some network and container mounts, Windows without junction support — gets copies
154
+ // instead of a failed install.
155
+ const linksSupported = scope.shareEngine ? canLink(scope.root) : false;
156
+ const { skills, files, links } = buildPlan({
157
+ sourceRoot,
158
+ scope,
159
+ targets: agents,
160
+ preferLinks: linksSupported,
161
+ });
126
162
 
127
163
  if (!json) {
128
164
  logger.step(`source: ${sourceRoot}`);
129
- logger.step(`project: ${root}`);
165
+ logger.step(`scope: ${scope.label}`);
130
166
  logger.step(`agents: ${agents.map((a) => a.id).join(', ')}`);
131
167
  logger.step(`skills: ${skills.length}`);
132
- }
133
-
134
- /** @type {Array<{path:string, sha256:string, bytes:number, owner:string, skill?:string, agent?:string, content:Buffer}>} */
135
- const planned = [];
136
-
137
- for (const agent of agents) {
138
- for (const skill of skills) {
139
- const files = skillFiles(sourceRoot, skill);
140
- for (const rel of files) {
141
- if (rel.startsWith('tests/') || rel.includes('/tests/')) continue;
142
- const abs = path.join(sourceRoot, 'skills', skill, rel);
143
- const content = fs.readFileSync(abs);
144
- const dest = toPosix(path.join(agent.skillsDir, skill, rel));
145
- planned.push({
146
- path: dest,
147
- sha256: hashBytes(content),
148
- bytes: content.length,
149
- owner: 'skill',
150
- skill,
151
- agent: agent.id,
152
- content,
153
- });
154
- }
155
- for (const bundled of bundleFilesForSkill(sourceRoot, skill)) {
156
- const dest = toPosix(path.join(agent.skillsDir, skill, bundled.rel));
157
- planned.push({
158
- path: dest,
159
- sha256: hashBytes(bundled.content),
160
- bytes: bundled.content.length,
161
- owner: 'skill',
162
- skill,
163
- agent: agent.id,
164
- content: bundled.content,
165
- });
166
- }
167
- }
168
-
169
- if (agent.wrapperFormat && agent.wrapperDir) {
170
- for (const skill of skills) {
171
- const meta = readSkillMeta(path.join(sourceRoot, 'skills', skill, 'SKILL.md'));
172
- if (!meta.name) continue;
173
- const { filename, content } = renderWrapper(agent.wrapperFormat, meta);
174
- const buf = Buffer.from(content, 'utf8');
175
- const dest = toPosix(path.join(agent.wrapperDir, filename));
176
- planned.push({
177
- path: dest,
178
- sha256: hashBytes(buf),
179
- bytes: buf.length,
180
- owner: 'wrapper',
181
- skill,
182
- agent: agent.id,
183
- content: buf,
184
- });
168
+ if (scope.shareEngine) {
169
+ logger.step(`shared engine: ${path.join(scope.qaRoot, 'engine')}`);
170
+ if (!linksSupported) {
171
+ logger.warn('this filesystem does not support directory links — copying skills instead');
172
+ logger.info(' → the install works the same; it uses more disk and each agent gets its own copy');
185
173
  }
186
174
  }
187
175
  }
188
176
 
189
- const byPath = new Map();
190
- for (const entry of planned) {
191
- const prev = byPath.get(entry.path);
192
- if (prev && prev.sha256 !== entry.sha256) {
193
- throw conflictError(`conflicting content for ${entry.path}`);
194
- }
195
- if (!prev) byPath.set(entry.path, entry);
177
+ const { unique: uniqueFiles, conflicts: contentConflicts } = dedupePlan(files);
178
+ if (contentConflicts.length > 0) {
179
+ throw conflictError(`conflicting content for ${contentConflicts[0]}`);
196
180
  }
197
- const unique = [...byPath.values()];
181
+ const { unique: uniqueLinks } = dedupePlan(links);
182
+ const unique = [...uniqueFiles, ...uniqueLinks];
198
183
 
199
- const priorLock = readLock(root);
184
+ const priorLock = readLock(root, scope.lockfile);
200
185
  const conflicts = detectConflicts({
201
186
  projectRoot: root,
202
187
  planned: unique,
@@ -220,10 +205,42 @@ export async function executeInstall({
220
205
  reportProgress(INSTALL_STEPS[0].label, 1);
221
206
 
222
207
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
223
- const backupDir = path.join(root, BACKUP_DIR, stamp);
208
+ // A scope that owns a directory keeps its backups inside it. Otherwise a global
209
+ // install writes them to `~/.qa/backups`, which is loose in the user's home — the
210
+ // exact thing owning a directory was meant to stop.
211
+ const backupRoot = scope.qaRootRelative ? path.join(scope.qaRootRelative, 'backups') : BACKUP_DIR;
212
+ const backupDir = path.join(root, backupRoot, stamp);
224
213
  const tx = new Transaction(root, backupDir, { dryRun });
225
214
  for (const entry of unique) {
226
- tx.write(entry.path, entry.content);
215
+ if (entry.owner === 'link') tx.link(entry.path, entry.linkTarget);
216
+ else tx.write(entry.path, entry.content);
217
+ }
218
+
219
+ // Files the previous install owned that this one does not: remove them.
220
+ //
221
+ // Without this, anything that leaves the pack between versions stays on disk
222
+ // forever, and `verify` cannot see it — verify checks that lockfile entries are
223
+ // present and unmodified, and an orphan is in no lockfile. Upgrading 0.9.1 to
224
+ // 0.9.3, which replaced the Python engine with a Node one, left 154 dead Python
225
+ // files in the user's repository, reported as a clean install and ready to be
226
+ // committed.
227
+ //
228
+ // Only files the pack itself wrote are touched: the candidates come from the
229
+ // prior lockfile, which is the record of what this installer created. Each goes
230
+ // through the same Transaction as a write, so it is backed up first and restored
231
+ // if any later step fails.
232
+ const orphans = [];
233
+ if (priorLock && !dryRun) {
234
+ const stillOwned = new Set(unique.map((entry) => entry.path));
235
+ for (const previous of priorLock.files ?? []) {
236
+ if (previous.path === scope.lockfile || stillOwned.has(previous.path)) continue;
237
+ const absolute = path.join(root, previous.path);
238
+ // A stale link still counts as present even when its target is gone, so lstat
239
+ // rather than exists — otherwise a broken link survives every upgrade.
240
+ if (!fs.existsSync(absolute) && readLinkTarget(absolute) === null) continue;
241
+ orphans.push(previous.path);
242
+ tx.delete(previous.path);
243
+ }
227
244
  }
228
245
 
229
246
  reportProgress(INSTALL_STEPS[1].label, 2);
@@ -237,18 +254,24 @@ export async function executeInstall({
237
254
  skillsDir: a.skillsDir,
238
255
  detected: a.detected,
239
256
  })),
240
- files: unique.map(({ path: p, sha256, bytes, owner, skill, agent }) => ({
257
+ // Shared entries the engine, and the canonical skill tree belong to no single
258
+ // agent, so the optional keys are omitted rather than set to undefined. The schema
259
+ // types them as strings, and a present-but-undefined key fails that as loudly as a
260
+ // wrong one.
261
+ files: unique.map(({ path: p, sha256, bytes, owner, skill, agent, linkTarget }) => ({
241
262
  path: p,
242
263
  sha256,
243
264
  bytes,
244
265
  owner,
245
- skill,
246
- agent,
266
+ ...(skill ? { skill } : {}),
267
+ ...(agent ? { agent } : {}),
268
+ ...(linkTarget ? { linkTarget } : {}),
247
269
  })),
248
270
  now: new Date().toISOString(),
271
+ scope,
249
272
  });
250
273
  if (!dryRun) {
251
- tx.write(LOCKFILE, Buffer.from(serializeLock(lock), 'utf8'));
274
+ tx.write(scope.lockfile, Buffer.from(serializeLock(lock), 'utf8'));
252
275
  }
253
276
 
254
277
  const summary = tx.commit();
@@ -258,7 +281,7 @@ export async function executeInstall({
258
281
 
259
282
  let validation = null;
260
283
  if (!dryRun && !skipValidate) {
261
- validation = validateInstall(root);
284
+ validation = validateInstall(root, { scope });
262
285
  if (!validation.ok) {
263
286
  const failed = validation.checks.filter((c) => c.hard && !c.ok);
264
287
  throw verifyError(
@@ -274,20 +297,38 @@ export async function executeInstall({
274
297
  if (dryRun) {
275
298
  if (!json) logger.ok(`dry run: would write ${summary.written} file(s)`);
276
299
  } else if (!json) {
277
- logger.ok(`installed ${unique.length} file(s); lockfile ${lockPath(root)}`);
300
+ const linked = unique.filter((entry) => entry.owner === 'link').length;
301
+ logger.ok(
302
+ `installed ${unique.length - linked} file(s)` +
303
+ (linked > 0 ? ` and ${linked} link(s)` : '') +
304
+ `; lockfile ${scopeLockPath(scope)}`,
305
+ );
306
+ if (orphans.length > 0) {
307
+ logger.ok(`removed ${orphans.length} file(s) the previous version owned and this one does not`);
308
+ }
278
309
  for (const step of INSTALL_STEPS) logger.ok(step.label);
279
310
  reportInvocation(agents, logger);
280
- reportFrameworkFit(root, logger);
311
+ // A global install has no project to inspect, and reporting "no framework detected"
312
+ // about the user's home directory would be noise pretending to be a diagnosis.
313
+ if (scope.kind !== 'global') reportFrameworkFit(root, logger);
314
+ reportUnserved(unserved, logger);
281
315
  }
282
316
 
283
317
  return {
284
318
  ok: true,
285
319
  dryRun,
320
+ scope: scope.kind,
321
+ root,
322
+ // Kept under its original name so every existing caller and test keeps working.
286
323
  projectRoot: root,
324
+ qaRoot: scope.qaRoot,
287
325
  agents: agents.map((a) => a.id),
326
+ unservedAgents: unserved,
288
327
  skills: skills.length,
289
- files: unique.length,
290
- lockfile: dryRun ? null : LOCKFILE,
328
+ files: unique.filter((entry) => entry.owner !== 'link').length,
329
+ links: unique.filter((entry) => entry.owner === 'link').length,
330
+ lockfile: dryRun ? null : scope.lockfile,
331
+ removed: orphans,
291
332
  validation,
292
333
  };
293
334
  }
@@ -296,17 +337,45 @@ export async function runInstall(argv, { log } = {}) {
296
337
  const opts = parseCommonFlags(argv);
297
338
  const logger = log ?? createLogger();
298
339
  if (opts.help) {
299
- logger.result(`Usage: qa install [--agent <id>]... [--force] [--dry-run] [--yes] [--json] [--project <dir>]
340
+ logger.result(`Usage: qa install [scope] [--agent <id>]... [--all-agents]
341
+ [--force] [--dry-run] [--yes] [--json]
342
+
343
+ Scope — choose one; --project is the default:
344
+
345
+ --global, -g Install once for this machine, in ~/.qa-engineer.
346
+ One engine, one copy of the skills, linked into the
347
+ user-level skills directory of every host that has one.
348
+ Every project then works with no per-project install.
349
+ Override the location with QA_ENGINEER_HOME.
350
+
351
+ --workspace, -w Install once at the root of the monorepo containing the
352
+ current directory, shared by every package in it.
353
+ Detects pnpm, npm/yarn workspaces, Nx, Turborepo, Lerna,
354
+ Rush, Go, and Cargo.
300
355
 
301
- Copy QA Engineer Pack skills into Agent Skills discovery paths
302
- (.agents/skills/ and .claude/skills/ when applicable), write qa-lock.json,
303
- and generate thin slash wrappers for agents that need them.`);
356
+ --project [dir], -C Install into one project (default: this directory).
357
+ Self-contained: the engine travels inside each skill, so
358
+ the repository works on a machine with nothing installed.
359
+
360
+ Other:
361
+ --all-agents Install for every host that has a user-level directory,
362
+ not just the ones detected here.
363
+ --agent <id> Target a specific host; repeatable.
364
+ --force Overwrite files a previous install does not own.
365
+ --dry-run Report what would change and write nothing.`);
304
366
  return EXIT.OK;
305
367
  }
306
368
 
369
+ const scope = resolveScope({
370
+ global: opts.global,
371
+ workspace: opts.workspace,
372
+ project: opts.project,
373
+ });
374
+
307
375
  const result = await executeInstall({
308
- projectRoot: opts.project ?? process.cwd(),
376
+ scope,
309
377
  agentIds: opts.agents,
378
+ allAgents: opts.allAgents,
310
379
  force: opts.force,
311
380
  dryRun: opts.dryRun,
312
381
  json: opts.json,
@@ -1,7 +1,7 @@
1
1
  // `qa repair` — fix common install problems by reinstalling pack-owned files.
2
2
 
3
3
  import { EXIT, LOCKFILE } from '../constants.mjs';
4
- import { resolveProjectRoot } from '../core/paths.mjs';
4
+ import { resolveOperatingScope } from '../core/scope.mjs';
5
5
  import { readLock } from '../core/lockfile.mjs';
6
6
  import { parseCommonFlags } from '../cli/flags.mjs';
7
7
  import { executeInstall } from './install.mjs';
@@ -21,9 +21,10 @@ Repair a broken or drifted installation:
21
21
  return EXIT.OK;
22
22
  }
23
23
 
24
- const root = resolveProjectRoot(opts.project ?? process.cwd());
25
- const prior = readLock(root);
26
- const before = validateInstall(root);
24
+ const scope = resolveOperatingScope(opts);
25
+ const root = scope.root;
26
+ const prior = readLock(root, scope.lockfile);
27
+ const before = validateInstall(root, { scope });
27
28
 
28
29
  if (!opts.json) {
29
30
  logger.step(`repairing ${root}`);
@@ -40,7 +41,10 @@ Repair a broken or drifted installation:
40
41
  : (prior?.agents ?? []).map((a) => a.id).filter(Boolean);
41
42
 
42
43
  const result = await executeInstall({
43
- projectRoot: root,
44
+ // The scope the command resolved, not just its root: without it the reinstall
45
+ // reverts a global install to a project-shaped one — 1225 files and a lockfile in
46
+ // the wrong place — while reporting success.
47
+ scope,
44
48
  agentIds,
45
49
  force: true,
46
50
  dryRun: opts.dryRun,
@@ -48,7 +52,7 @@ Repair a broken or drifted installation:
48
52
  log: logger,
49
53
  });
50
54
 
51
- const after = opts.dryRun ? before : validateInstall(root);
55
+ const after = opts.dryRun ? before : validateInstall(root, { scope });
52
56
 
53
57
  if (!opts.json) {
54
58
  if (after.ok) logger.ok('repair complete — installation validated');
@@ -1,7 +1,7 @@
1
1
  // `qa self-test` — verify an installation is operational.
2
2
 
3
3
  import { EXIT } from '../constants.mjs';
4
- import { resolveProjectRoot } from '../core/paths.mjs';
4
+ import { resolveOperatingScope } from '../core/scope.mjs';
5
5
  import { parseCommonFlags } from '../cli/flags.mjs';
6
6
  import { validateInstall } from '../core/validate-install.mjs';
7
7
  import { createLogger } from '../core/logger.mjs';
@@ -17,8 +17,9 @@ Prints PASS/FAIL per check. Does not invoke live AI agents.`);
17
17
  return EXIT.OK;
18
18
  }
19
19
 
20
- const root = resolveProjectRoot(opts.project ?? process.cwd());
21
- const { ok, checks } = validateInstall(root);
20
+ const scope = resolveOperatingScope(opts);
21
+ const root = scope.root;
22
+ const { ok, checks } = validateInstall(root, { scope });
22
23
 
23
24
  if (!opts.json) {
24
25
  logger.step(`self-test — ${root}`);
@@ -16,9 +16,9 @@
16
16
  import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { EXIT, LOCKFILE, BACKUP_DIR } from '../constants.mjs';
19
- import { resolveProjectRoot } from '../core/paths.mjs';
19
+ import { resolveOperatingScope } from '../core/scope.mjs';
20
20
  import { readLock, lockPath } from '../core/lockfile.mjs';
21
- import { hashFile } from '../core/hash.mjs';
21
+ import { entryDigest } from '../core/integrity.mjs';
22
22
  import { Transaction, pruneEmptyDirs } from '../core/fs-safe.mjs';
23
23
  import { conflictError, verifyError } from '../core/errors.mjs';
24
24
  import { createLogger } from '../core/logger.mjs';
@@ -40,7 +40,7 @@ function findPycache(dir) {
40
40
  return found;
41
41
  }
42
42
 
43
- export async function runUninstall(argv, { log } = {}) {
43
+ export async function runUninstall(argv, { log, env = process.env } = {}) {
44
44
  const opts = parseCommonFlags(argv);
45
45
  const logger = log ?? createLogger();
46
46
  if (opts.help) {
@@ -54,8 +54,11 @@ Only pack-owned files are removed; anything else is left alone.
54
54
  return EXIT.OK;
55
55
  }
56
56
 
57
- const root = resolveProjectRoot(opts.project ?? process.cwd());
58
- const lock = readLock(root);
57
+ // `env` is injected by the test suite so a global uninstall can be exercised
58
+ // against a temporary home rather than the developer's own.
59
+ const scope = resolveOperatingScope({ ...opts, env });
60
+ const root = scope.root;
61
+ const lock = readLock(root, scope.lockfile);
59
62
  if (!lock) {
60
63
  if (!opts.json) {
61
64
  logger.error(`FAIL no ${LOCKFILE} in ${root}`);
@@ -73,7 +76,7 @@ Only pack-owned files are removed; anything else is left alone.
73
76
  missing.push(entry.path);
74
77
  continue;
75
78
  }
76
- if (hashFile(abs) !== entry.sha256) drifted.push(entry.path);
79
+ if (entryDigest(root, entry) !== entry.sha256) drifted.push(entry.path);
77
80
  present.push(entry.path);
78
81
  }
79
82
 
@@ -97,9 +100,12 @@ Only pack-owned files are removed; anything else is left alone.
97
100
  }
98
101
 
99
102
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
100
- const tx = new Transaction(root, path.join(root, BACKUP_DIR, stamp), { dryRun: opts.dryRun });
103
+ // Same rule as install: a scope that owns a directory keeps its backups inside it,
104
+ // rather than dropping `.qa/backups` into the user's home.
105
+ const backupRoot = scope.qaRootRelative ? path.join(scope.qaRootRelative, 'backups') : BACKUP_DIR;
106
+ const tx = new Transaction(root, path.join(root, backupRoot, stamp), { dryRun: opts.dryRun });
101
107
  for (const rel of present) tx.delete(rel);
102
- if (fs.existsSync(lockPath(root))) tx.delete(LOCKFILE);
108
+ if (fs.existsSync(path.join(root, scope.lockfile))) tx.delete(scope.lockfile);
103
109
  const summary = tx.commit();
104
110
 
105
111
  // Byproducts of the pack's own bundled code: Python writes __pycache__ next to
@@ -1,7 +1,8 @@
1
1
  // `qa update` — refresh an install from the current pack source.
2
2
 
3
3
  import { EXIT } from '../constants.mjs';
4
- import { resolveProjectRoot, resolveSourceRoot } from '../core/paths.mjs';
4
+ import { resolveSourceRoot } from '../core/paths.mjs';
5
+ import { resolveOperatingScope } from '../core/scope.mjs';
5
6
  import { readLock } from '../core/lockfile.mjs';
6
7
  import { parseCommonFlags } from '../cli/flags.mjs';
7
8
  import { executeInstall } from './install.mjs';
@@ -20,9 +21,10 @@ Always re-validates after updating.`);
20
21
  return EXIT.OK;
21
22
  }
22
23
 
23
- const root = resolveProjectRoot(opts.project ?? process.cwd());
24
+ const scope = resolveOperatingScope(opts);
25
+ const root = scope.root;
24
26
  const sourceRoot = resolveSourceRoot();
25
- const lock = readLock(root);
27
+ const lock = readLock(root, scope.lockfile);
26
28
  const installedVersion = lock?.pack?.version ?? null;
27
29
 
28
30
  if (!opts.json) {
@@ -43,7 +45,10 @@ Always re-validates after updating.`);
43
45
  : (lock?.agents ?? []).map((a) => a.id).filter(Boolean);
44
46
 
45
47
  const result = await executeInstall({
46
- projectRoot: root,
48
+ // The scope the command resolved, not just its root: without it the reinstall
49
+ // reverts a global install to a project-shaped one — 1225 files and a lockfile in
50
+ // the wrong place — while reporting success.
51
+ scope,
47
52
  agentIds,
48
53
  force: true,
49
54
  dryRun: opts.dryRun,
@@ -3,9 +3,9 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { EXIT, LOCKFILE } from '../constants.mjs';
6
- import { resolveProjectRoot } from '../core/paths.mjs';
6
+ import { resolveOperatingScope } from '../core/scope.mjs';
7
7
  import { readLock } from '../core/lockfile.mjs';
8
- import { hashFile } from '../core/hash.mjs';
8
+ import { entryDigest } from '../core/integrity.mjs';
9
9
  import { verifyError } from '../core/errors.mjs';
10
10
  import { createLogger } from '../core/logger.mjs';
11
11
  import { parseCommonFlags } from '../cli/flags.mjs';
@@ -21,8 +21,9 @@ On failure: run qa repair`);
21
21
  return EXIT.OK;
22
22
  }
23
23
 
24
- const root = resolveProjectRoot(opts.project ?? process.cwd());
25
- const lock = readLock(root);
24
+ const scope = resolveOperatingScope(opts);
25
+ const root = scope.root;
26
+ const lock = readLock(root, scope.lockfile);
26
27
  if (!lock) {
27
28
  if (!opts.json) {
28
29
  logger.error(`FAIL no ${LOCKFILE}`);
@@ -33,14 +34,14 @@ On failure: run qa repair`);
33
34
 
34
35
  const problems = [];
35
36
  for (const entry of lock.files) {
36
- const abs = path.join(root, entry.path);
37
- if (!fs.existsSync(abs)) {
38
- problems.push({ path: entry.path, reason: 'missing' });
39
- continue;
40
- }
41
- const actual = hashFile(abs);
42
- if (actual !== entry.sha256) {
43
- problems.push({ path: entry.path, reason: 'hash mismatch' });
37
+ const actual = entryDigest(root, entry);
38
+ if (actual === null) {
39
+ problems.push({ path: entry.path, reason: entry.owner === 'link' ? 'link missing' : 'missing' });
40
+ } else if (actual !== entry.sha256) {
41
+ problems.push({
42
+ path: entry.path,
43
+ reason: entry.owner === 'link' ? 'link points somewhere else' : 'hash mismatch',
44
+ });
44
45
  }
45
46
  }
46
47
 
@@ -11,6 +11,19 @@ export const LOCKFILE = 'qa-lock.json';
11
11
  export const QA_DIR = '.qa';
12
12
  export const BACKUP_DIR = `${QA_DIR}/backups`;
13
13
 
14
+ // The directory QA Engineer owns. In a global install it sits in the user's home; in a
15
+ // workspace install it sits at the monorepo root. Same name in both, so a developer who
16
+ // has seen one recognises the other.
17
+ export const QA_HOME_DIR_NAME = '.qa-engineer';
18
+
19
+ // Full override for where the global install lives. Also what makes global installs
20
+ // testable — the suite points it at a temporary directory instead of a real $HOME.
21
+ export const QA_HOME_ENV = 'QA_ENGINEER_HOME';
22
+
23
+ // Where the shared engine lands inside a qaRoot, and where the canonical skills live.
24
+ export const SHARED_ENGINE_DIR = 'engine';
25
+ export const SHARED_SKILLS_STORE = 'skills';
26
+
14
27
  // Marker line prepended to every generated invocation wrapper. Wrappers are
15
28
  // disposable build artifacts; this line tells humans and `verify` that the file
16
29
  // is pack-owned and regenerated, never hand-edited.