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
@@ -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,15 @@ 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);
227
217
  }
228
218
 
229
219
  // Files the previous install owned that this one does not: remove them.
@@ -243,8 +233,11 @@ export async function executeInstall({
243
233
  if (priorLock && !dryRun) {
244
234
  const stillOwned = new Set(unique.map((entry) => entry.path));
245
235
  for (const previous of priorLock.files ?? []) {
246
- if (previous.path === LOCKFILE || stillOwned.has(previous.path)) continue;
247
- if (!fs.existsSync(path.join(root, previous.path))) continue;
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;
248
241
  orphans.push(previous.path);
249
242
  tx.delete(previous.path);
250
243
  }
@@ -261,18 +254,24 @@ export async function executeInstall({
261
254
  skillsDir: a.skillsDir,
262
255
  detected: a.detected,
263
256
  })),
264
- 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 }) => ({
265
262
  path: p,
266
263
  sha256,
267
264
  bytes,
268
265
  owner,
269
- skill,
270
- agent,
266
+ ...(skill ? { skill } : {}),
267
+ ...(agent ? { agent } : {}),
268
+ ...(linkTarget ? { linkTarget } : {}),
271
269
  })),
272
270
  now: new Date().toISOString(),
271
+ scope,
273
272
  });
274
273
  if (!dryRun) {
275
- tx.write(LOCKFILE, Buffer.from(serializeLock(lock), 'utf8'));
274
+ tx.write(scope.lockfile, Buffer.from(serializeLock(lock), 'utf8'));
276
275
  }
277
276
 
278
277
  const summary = tx.commit();
@@ -282,7 +281,7 @@ export async function executeInstall({
282
281
 
283
282
  let validation = null;
284
283
  if (!dryRun && !skipValidate) {
285
- validation = validateInstall(root);
284
+ validation = validateInstall(root, { scope });
286
285
  if (!validation.ok) {
287
286
  const failed = validation.checks.filter((c) => c.hard && !c.ok);
288
287
  throw verifyError(
@@ -298,23 +297,37 @@ export async function executeInstall({
298
297
  if (dryRun) {
299
298
  if (!json) logger.ok(`dry run: would write ${summary.written} file(s)`);
300
299
  } else if (!json) {
301
- 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
+ );
302
306
  if (orphans.length > 0) {
303
307
  logger.ok(`removed ${orphans.length} file(s) the previous version owned and this one does not`);
304
308
  }
305
309
  for (const step of INSTALL_STEPS) logger.ok(step.label);
306
310
  reportInvocation(agents, logger);
307
- 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);
308
315
  }
309
316
 
310
317
  return {
311
318
  ok: true,
312
319
  dryRun,
320
+ scope: scope.kind,
321
+ root,
322
+ // Kept under its original name so every existing caller and test keeps working.
313
323
  projectRoot: root,
324
+ qaRoot: scope.qaRoot,
314
325
  agents: agents.map((a) => a.id),
326
+ unservedAgents: unserved,
315
327
  skills: skills.length,
316
- files: unique.length,
317
- 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,
318
331
  removed: orphans,
319
332
  validation,
320
333
  };
@@ -324,17 +337,45 @@ export async function runInstall(argv, { log } = {}) {
324
337
  const opts = parseCommonFlags(argv);
325
338
  const logger = log ?? createLogger();
326
339
  if (opts.help) {
327
- 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]
328
342
 
329
- Copy QA Engineer Pack skills into Agent Skills discovery paths
330
- (.agents/skills/ and .claude/skills/ when applicable), write qa-lock.json,
331
- and generate thin slash wrappers for agents that need them.`);
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.
355
+
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.`);
332
366
  return EXIT.OK;
333
367
  }
334
368
 
369
+ const scope = resolveScope({
370
+ global: opts.global,
371
+ workspace: opts.workspace,
372
+ project: opts.project,
373
+ });
374
+
335
375
  const result = await executeInstall({
336
- projectRoot: opts.project ?? process.cwd(),
376
+ scope,
337
377
  agentIds: opts.agents,
378
+ allAgents: opts.allAgents,
338
379
  force: opts.force,
339
380
  dryRun: opts.dryRun,
340
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.
@@ -6,7 +6,7 @@
6
6
 
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { hashFile } from './hash.mjs';
9
+ import { entryDigest, entryPresent } from './integrity.mjs';
10
10
 
11
11
  /**
12
12
  * @param {object} args
@@ -19,10 +19,11 @@ export function detectConflicts({ projectRoot, planned, priorLock }) {
19
19
  const owned = new Set((priorLock?.files ?? []).map((f) => f.path));
20
20
  const conflicts = [];
21
21
  for (const entry of planned) {
22
- const abs = path.join(projectRoot, entry.path);
23
- if (!fs.existsSync(abs)) continue; // brand-new file, no conflict
22
+ // `entryPresent` rather than `existsSync`: a link whose target is gone still
23
+ // occupies the path, and overwriting it blind would lose where it pointed.
24
+ if (!entryPresent(projectRoot, entry)) continue; // brand-new path, no conflict
24
25
  if (owned.has(entry.path)) continue; // pack-owned, safe to update
25
- if (hashFile(abs) === entry.sha256) continue; // identical content, no-op
26
+ if (entryDigest(projectRoot, entry) === entry.sha256) continue; // already correct, no-op
26
27
  conflicts.push({ path: entry.path, reason: 'exists and is not owned by a previous install' });
27
28
  }
28
29
  return conflicts;