adversarial-review-gate 2.0.2 → 2.1.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.
@@ -18,16 +18,21 @@
18
18
  // In dry-run mode: print every planned write path and its note, then exit 0
19
19
  // WITHOUT writing any files.
20
20
 
21
- import { readFile, writeFile, mkdir } from "node:fs/promises";
21
+ import { readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
22
22
  import { existsSync } from "node:fs";
23
23
  import path from "node:path";
24
- import os from "node:os";
24
+ import { fileURLToPath } from "node:url";
25
25
 
26
26
  import { mergeConfig, applyPolicyFloor, DEFAULT_CONFIG } from "../core/config.js";
27
27
  import { HOSTS } from "../hosts/index.js";
28
- import { plannedClaudeCodeWrites } from "../hosts/claude-code.js";
28
+ import {
29
+ plannedClaudeCodeWrites,
30
+ claudeCodeSettingsPath,
31
+ } from "../hosts/claude-code.js";
29
32
  import { wrapperInstructions } from "../hosts/wrapper.js";
30
33
  import { createReviewer } from "../reviewers/index.js";
34
+ import { resolveExecutable } from "../core/process.js";
35
+ import { resolveHomeDir } from "../core/load-config.js";
31
36
 
32
37
  // Path constants (relative to cwd / home).
33
38
  const PROJECT_CONFIG_REL = path.join(".adversarial-review", "config.json");
@@ -35,6 +40,27 @@ const USER_POLICY_REL = path.join(".adversarial-review", "policy.json");
35
40
  const USER_INSTALL_REL = path.join(".adversarial-review", "install.json");
36
41
  const LEGACY_CONFIG_REL = path.join("hooks", "config.json");
37
42
 
43
+ // Read-only opencode agent: where it lives in the user's home, and the bundled
44
+ // source that ships inside the package. The agent MUST be a `primary` opencode
45
+ // agent (not a subagent) or opencode falls back to the writable default agent.
46
+ const OPENCODE_AGENT_REL = path.join(
47
+ ".config",
48
+ "opencode",
49
+ "agent",
50
+ "adversarial-reviewer.md"
51
+ );
52
+ const BUNDLED_OPENCODE_AGENT_PATH = fileURLToPath(
53
+ new URL("../integrations/opencode/adversarial-reviewer.agent.md", import.meta.url)
54
+ );
55
+
56
+ // Default config block written for an opencode reviewer so enforced-mode
57
+ // isolation (readOnly && noEdit) passes and the read-only agent is selected.
58
+ const OPENCODE_REVIEWER_DEFAULTS = Object.freeze({
59
+ readOnlyConfig: true,
60
+ agent: "adversarial-reviewer",
61
+ timeoutSec: 180,
62
+ });
63
+
38
64
  // ---------------------------------------------------------------------------
39
65
  // Argument parsing
40
66
  // ---------------------------------------------------------------------------
@@ -43,18 +69,23 @@ const LEGACY_CONFIG_REL = path.join("hooks", "config.json");
43
69
  * Parse the install command's argv array into structured options.
44
70
  *
45
71
  * @param {string[]} argv
46
- * @returns {{ hosts: string[], reviewerMap: Map<string,string>, dryRun: boolean, projectConfigPath: string|null }}
72
+ * @returns {{ hosts: string[], reviewerMap: Map<string,string>, dryRun: boolean, projectConfigPath: string|null, userScope: boolean }}
47
73
  */
48
74
  function parseArgs(argv) {
49
75
  const hosts = [];
50
76
  const reviewerMap = new Map();
51
77
  let dryRun = false;
52
78
  let projectConfigPath = null;
79
+ let userScope = false;
53
80
 
54
81
  for (let i = 0; i < argv.length; i++) {
55
82
  const arg = argv[i];
56
83
  if (arg === "--dry-run") {
57
84
  dryRun = true;
85
+ } else if (arg === "--user" || arg === "--global") {
86
+ // Machine-wide install: write to <home>/.adversarial-review/config.json
87
+ // and merge hooks into <home>/.claude/settings.json instead of cwd.
88
+ userScope = true;
58
89
  } else if (arg === "--hosts" && argv[i + 1]) {
59
90
  // Accept either `--hosts a,b` or `--hosts a --hosts b`.
60
91
  argv[i + 1].split(",").forEach((h) => hosts.push(h.trim()));
@@ -74,7 +105,7 @@ function parseArgs(argv) {
74
105
  }
75
106
  }
76
107
 
77
- return { hosts, reviewerMap, dryRun, projectConfigPath };
108
+ return { hosts, reviewerMap, dryRun, projectConfigPath, userScope };
78
109
  }
79
110
 
80
111
  // ---------------------------------------------------------------------------
@@ -93,13 +124,92 @@ async function readJsonTolerant(filePath) {
93
124
  }
94
125
  }
95
126
 
96
- /** Resolve home directory from env, falling back to os.homedir(). */
97
- function homeDir(env) {
98
- if (env) {
99
- const fromEnv = env.HOME || env.USERPROFILE;
100
- if (fromEnv) return fromEnv;
127
+ /**
128
+ * Read an existing Claude Code settings.json for merging. Distinguishes
129
+ * three cases so the caller can decide whether to back up the original:
130
+ * - missing/unreadable: { settings: {}, corrupt: false } — nothing to back up.
131
+ * - present but invalid JSON: { settings: {}, corrupt: true } — back up first.
132
+ * - present + valid object: { settings: <obj>, corrupt: false }.
133
+ *
134
+ * @param {string} filePath
135
+ * @returns {Promise<{ settings: object, corrupt: boolean }>}
136
+ */
137
+ async function readSettingsForMerge(filePath) {
138
+ let raw;
139
+ try {
140
+ raw = await readFile(filePath, "utf8");
141
+ } catch {
142
+ return { settings: {}, corrupt: false }; // Missing: nothing to back up.
143
+ }
144
+ try {
145
+ const parsed = JSON.parse(raw);
146
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
147
+ return { settings: parsed, corrupt: false };
148
+ }
149
+ // Valid JSON but not an object (e.g. an array or scalar) — treat as corrupt
150
+ // so we preserve the original via backup rather than silently dropping it.
151
+ return { settings: {}, corrupt: true };
152
+ } catch {
153
+ return { settings: {}, corrupt: true };
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Back up a corrupt settings.json to settings.json.bak before it is overwritten
159
+ * by the merged (from-scratch) result. Best-effort; never throws.
160
+ *
161
+ * @param {string} filePath
162
+ * @param {object} io - { stdout }
163
+ */
164
+ async function backupCorruptSettings(filePath, io) {
165
+ const bakPath = `${filePath}.bak`;
166
+ try {
167
+ await copyFile(filePath, bakPath);
168
+ io.stdout.write(
169
+ ` NOTE: ${filePath} was not valid JSON; backed it up to ${bakPath} before merging.\n`
170
+ );
171
+ } catch {
172
+ // If the backup itself fails we still proceed — but note it.
173
+ io.stdout.write(
174
+ ` WARNING: ${filePath} was not valid JSON and could not be backed up; merging from scratch.\n`
175
+ );
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Normalize a directory path into a STABLE install-registry key so the same
181
+ * project never produces duplicate registry entries. We path.resolve() to an
182
+ * absolute, normalized form and, on win32, lowercase a leading drive letter
183
+ * (`D:\` and `d:\` denote the same path). Other path casing is left intact
184
+ * because POSIX filesystems are case-sensitive.
185
+ *
186
+ * @param {string} dir
187
+ * @returns {string}
188
+ */
189
+ function normalizeRegistryKey(dir) {
190
+ let resolved = path.resolve(dir);
191
+ if (process.platform === "win32" && /^[a-zA-Z]:/.test(resolved)) {
192
+ resolved = resolved[0].toLowerCase() + resolved.slice(1);
193
+ }
194
+ return resolved;
195
+ }
196
+
197
+ /**
198
+ * Pick the command used to invoke this package from hooks/wrappers.
199
+ *
200
+ * Prefers the direct bin name `adversarial-review-gate` when it resolves on
201
+ * PATH (a global install — faster, no npx resolution per Stop hook). Falls back
202
+ * to `npx adversarial-review-gate` otherwise, which always works.
203
+ *
204
+ * @param {object} env - environment variables
205
+ * @returns {Promise<{ command: string, direct: boolean }>}
206
+ */
207
+ async function resolveHookBinCommand(env) {
208
+ const resolved = await resolveExecutable("adversarial-review-gate", env);
209
+ if (resolved) {
210
+ return { command: "adversarial-review-gate", direct: true };
101
211
  }
102
- return os.homedir();
212
+ return { command: "npx adversarial-review-gate", direct: false };
103
213
  }
104
214
 
105
215
  // ---------------------------------------------------------------------------
@@ -163,8 +273,18 @@ async function readLegacyConfig(cwd) {
163
273
  // ---------------------------------------------------------------------------
164
274
 
165
275
  /**
166
- * Verify that a reviewer id is available (its binary resolves on PATH).
167
- * "none" is always treated as available.
276
+ * Verify that a reviewer id is available FOR INSTALL (its binary resolves on
277
+ * PATH and answers --version). "none" is always treated as available.
278
+ *
279
+ * INSTALL-TIME SEMANTICS: this uses { requireAgent: false } so the opencode
280
+ * adapter checks ONLY the binary + version and SKIPS the `opencode agent list`
281
+ * / `reviewer_agent_missing` check. This breaks a chicken-and-egg: the installer
282
+ * is the very thing that CREATES the read-only agent (FIX 2 below), so on a
283
+ * clean machine the agent does not exist yet and the full verify() would reject
284
+ * the install before the agent could ever be created. A MISSING BINARY or a
285
+ * failing --version (missing_binary / version_check_failed) STILL rejects — only
286
+ * agent-existence is skipped. Other adapters (codex/custom) ignore the option.
287
+ * Runtime (makeReviewerRunner) and `doctor` keep the full verify() (with agent).
168
288
  *
169
289
  * @param {string} reviewerId
170
290
  * @param {object} config - effective config (used by createReviewer)
@@ -175,7 +295,7 @@ async function checkReviewerAvailability(reviewerId, config, env) {
175
295
  if (reviewerId === "none") return { ok: true };
176
296
  try {
177
297
  const adapter = createReviewer(reviewerId, config);
178
- return adapter.verify(env);
298
+ return adapter.verify(env, { requireAgent: false });
179
299
  } catch (err) {
180
300
  return { ok: false, reason: err.message };
181
301
  }
@@ -190,14 +310,20 @@ async function checkReviewerAvailability(reviewerId, config, env) {
190
310
  * Writes atomically by writing to a temp file and renaming (best-effort on
191
311
  * Windows where rename semantics differ; we do a two-step write+rename).
192
312
  *
313
+ * The file mode is parameterized PER FILE: team-shared/committed files
314
+ * (project config, .claude/settings.json) are written 0o644 so collaborators
315
+ * can read them, while user-level secrets-adjacent files (user config, registry,
316
+ * state) stay 0o600. Defaults to 0o600 (the safe default).
317
+ *
193
318
  * @param {string} filePath
194
319
  * @param {string} content
320
+ * @param {number} [mode=0o600]
195
321
  */
196
- async function atomicWrite(filePath, content) {
322
+ async function atomicWrite(filePath, content, mode = 0o600) {
197
323
  const dir = path.dirname(filePath);
198
324
  await mkdir(dir, { recursive: true });
199
325
  const tmp = `${filePath}.tmp${Date.now()}`;
200
- await writeFile(tmp, content, { encoding: "utf8", mode: 0o600 });
326
+ await writeFile(tmp, content, { encoding: "utf8", mode });
201
327
  // node:fs rename is atomic on POSIX; on Windows it will overwrite on Node 14+.
202
328
  const { rename } = await import("node:fs/promises");
203
329
  await rename(tmp, filePath);
@@ -214,9 +340,15 @@ async function atomicWrite(filePath, content) {
214
340
  export async function installCommand(argv, io) {
215
341
  const cwd = io.cwd || process.cwd();
216
342
  const env = io.env || process.env;
217
- const home = homeDir(env);
343
+ const home = resolveHomeDir(env);
218
344
 
219
- const { hosts, reviewerMap, dryRun, projectConfigPath } = parseArgs(argv);
345
+ const { hosts, reviewerMap, dryRun, projectConfigPath, userScope } = parseArgs(argv);
346
+
347
+ // Scope base: the directory whose .adversarial-review/config.json and
348
+ // .claude/settings.json we write. User scope targets <home>; default targets
349
+ // the project <cwd>. The install registry + opencode agent always live under
350
+ // <home> regardless of scope.
351
+ const scopeBase = userScope ? home : cwd;
220
352
 
221
353
  // --- Require at least one host ---
222
354
  if (!hosts.length) {
@@ -232,8 +364,12 @@ export async function installCommand(argv, io) {
232
364
  const userPolicyPath = path.join(home, USER_POLICY_REL);
233
365
  const userPolicyFloor = await readJsonTolerant(userPolicyPath);
234
366
 
235
- // --- Load existing project config (from explicit path or default location) ---
236
- const projectConfigPath2 = projectConfigPath || path.join(cwd, PROJECT_CONFIG_REL);
367
+ // --- Load existing config to layer onto (scope-aware) ---
368
+ // For project scope this is <cwd>/.adversarial-review/config.json; for user
369
+ // scope it is <home>/.adversarial-review/config.json. An explicit
370
+ // --project-config path always wins.
371
+ const projectConfigPath2 =
372
+ projectConfigPath || path.join(scopeBase, PROJECT_CONFIG_REL);
237
373
  const existingProjectConfig = await readJsonTolerant(projectConfigPath2);
238
374
 
239
375
  // --- Read legacy config and merge ---
@@ -353,51 +489,100 @@ export async function installCommand(argv, io) {
353
489
  };
354
490
  }
355
491
 
492
+ // FIX 1: write a working reviewers config for any opencode reviewer.
493
+ // Without reviewers.opencode.readOnlyConfig:true the adapter reports
494
+ // capabilities {readOnly:false,noEdit:false}, so enforced-mode isolation
495
+ // (readOnly && noEdit) fails and makeReviewerRunner rejects every review with
496
+ // `reviewer_not_isolated`. For each DISTINCT selected-host mapping to
497
+ // opencode, merge the read-only defaults — without clobbering any reviewers
498
+ // section the user/project already set. (codex-as-reviewer already reports
499
+ // isolated, so it needs no config block.)
500
+ const reviewersConfig = { ...(baseProjectConfig.reviewers || {}) };
501
+ const usesOpencodeReviewer = hosts.some(
502
+ (host) => reviewerMap.get(host) === "opencode"
503
+ );
504
+ if (usesOpencodeReviewer) {
505
+ reviewersConfig.opencode = {
506
+ ...OPENCODE_REVIEWER_DEFAULTS,
507
+ // Preserve any explicit overrides the user already set for opencode.
508
+ ...(reviewersConfig.opencode || {}),
509
+ // Always assert isolation: a user who wrote a writable opencode block must
510
+ // not silently defeat enforced-mode isolation through this installer.
511
+ readOnlyConfig: true,
512
+ };
513
+ }
514
+
356
515
  const newProjectConfig = {
357
516
  ...baseProjectConfig,
358
517
  hosts: hostsConfig,
359
518
  };
519
+ // Only attach reviewers when there is something to write so buildProjectConfig
520
+ // ToWrite's `if (newProjectConfig.reviewers)` guard stays accurate.
521
+ if (Object.keys(reviewersConfig).length) {
522
+ newProjectConfig.reviewers = reviewersConfig;
523
+ }
360
524
 
361
525
  // Merge with DEFAULT_CONFIG and enforce policy floor.
362
526
  const resolvedConfig = mergeConfig(newProjectConfig, userPolicyFloor);
363
527
 
364
- // Serialize the project-level config (strip runtime defaults that came from
365
- // DEFAULT_CONFIG; keep only what was explicitly set or migrated).
366
- const configToWrite = buildProjectConfigToWrite(newProjectConfig, resolvedConfig);
528
+ // Serialize the config. For USER scope we write the full machine-wide config
529
+ // (always include policy.mode and the reviewers block) so the user-level
530
+ // defaults are explicit; for project scope we keep only what was explicitly
531
+ // set or migrated (no DEFAULT_CONFIG boilerplate).
532
+ const configToWrite = buildProjectConfigToWrite(
533
+ newProjectConfig,
534
+ resolvedConfig,
535
+ userScope
536
+ );
367
537
  const configJson = JSON.stringify(configToWrite, null, 2);
368
538
 
369
539
  // --- Collect planned writes ---
370
540
 
371
541
  const plannedWrites = [];
372
542
 
373
- // 1. Project config.
374
- const projectConfigOutPath = path.join(cwd, PROJECT_CONFIG_REL);
543
+ // 1. Config (scope-aware). Project scope -> <cwd>/.adversarial-review/...;
544
+ // user scope -> <home>/.adversarial-review/... — a team-shared/committed
545
+ // file in either case, so mode 0o644.
546
+ const projectConfigOutPath = path.join(scopeBase, PROJECT_CONFIG_REL);
375
547
  plannedWrites.push({
376
548
  path: projectConfigOutPath,
377
549
  content: configJson,
378
- note: "Project config (.adversarial-review/config.json)",
550
+ note: userScope
551
+ ? "User config (machine-wide defaults: ~/.adversarial-review/config.json)"
552
+ : "Project config (.adversarial-review/config.json)",
379
553
  type: "project-config",
554
+ mode: 0o644,
380
555
  });
381
556
 
382
- // 2. User-level install registry.
557
+ // 2. User-level install registry. Keyed by a NORMALIZED path so the same
558
+ // project never produces duplicate entries. User-level + secrets-adjacent:
559
+ // mode 0o600.
383
560
  const installRegistryPath = path.join(home, USER_INSTALL_REL);
384
561
  const existingRegistry = await readJsonTolerant(installRegistryPath);
385
562
  const registryEntry = {
386
563
  installedAt: new Date().toISOString(),
564
+ scope: userScope ? "user" : "project",
387
565
  hosts,
388
566
  reviewers: Object.fromEntries(reviewerMap),
389
567
  };
568
+ const registryKey = normalizeRegistryKey(userScope ? home : cwd);
390
569
  const updatedRegistry = {
391
570
  ...existingRegistry,
392
- [cwd]: registryEntry,
571
+ [registryKey]: registryEntry,
393
572
  };
394
573
  plannedWrites.push({
395
574
  path: installRegistryPath,
396
575
  content: JSON.stringify(updatedRegistry, null, 2),
397
576
  note: "User-level install registry (~/.adversarial-review/install.json)",
398
577
  type: "install-registry",
578
+ mode: 0o600,
399
579
  });
400
580
 
581
+ // FIX 3: pick the hook/wrapper command once. Prefer the direct bin name when
582
+ // it resolves on PATH (global install — no per-Stop npx resolution); else use
583
+ // `npx adversarial-review-gate`, which always works.
584
+ const hookBin = await resolveHookBinCommand(env);
585
+
401
586
  // 3. Per-host integration files (native) or wrapper instructions.
402
587
  const wrapperInstructionsList = [];
403
588
  for (const host of hosts) {
@@ -405,13 +590,33 @@ export async function installCommand(argv, io) {
405
590
  if (hostInfo.enforcement === "native-enforced") {
406
591
  // Native host: compute planned file writes.
407
592
  if (host === "claude-code") {
408
- const nativeWrites = plannedClaudeCodeWrites({ cwd });
593
+ // CRITICAL: read the existing settings.json so we DEEP-MERGE rather than
594
+ // clobber. If the file is corrupt, back it up to settings.json.bak before
595
+ // we overwrite it with the merged result (which starts from {}). The
596
+ // settings.json is a team-shared/committed file -> mode 0o644.
597
+ const settingsPath = claudeCodeSettingsPath(scopeBase);
598
+ const { settings: existingSettings, corrupt } =
599
+ await readSettingsForMerge(settingsPath);
600
+ if (corrupt && !dryRun) {
601
+ await backupCorruptSettings(settingsPath, io);
602
+ } else if (corrupt && dryRun) {
603
+ io.stdout.write(
604
+ ` NOTE: ${settingsPath} is not valid JSON; a real install would back ` +
605
+ `it up to settings.json.bak and start fresh.\n`
606
+ );
607
+ }
608
+ const nativeWrites = plannedClaudeCodeWrites({
609
+ baseDir: scopeBase,
610
+ binPath: hookBin.command,
611
+ existingSettings,
612
+ });
409
613
  for (const w of nativeWrites) {
410
614
  plannedWrites.push({
411
615
  path: w.path,
412
616
  content: w.content,
413
617
  note: w.note,
414
618
  type: "native-hook",
619
+ mode: 0o644,
415
620
  });
416
621
  }
417
622
  }
@@ -420,18 +625,51 @@ export async function installCommand(argv, io) {
420
625
  const instructions = wrapperInstructions({
421
626
  host,
422
627
  reviewer: reviewerMap.get(host),
628
+ binPath: hookBin.command,
423
629
  });
424
630
  wrapperInstructionsList.push(instructions);
425
631
  }
426
632
  }
427
633
 
634
+ // 4. FIX 2: ensure the opencode read-only agent exists when opencode is a
635
+ // chosen reviewer. opencode SILENTLY falls back to the writable default agent
636
+ // when this primary agent is missing, so the adapter's verify() rejects the
637
+ // setup with `reviewer_agent_missing` until it exists. We ship the agent in
638
+ // the package and copy it on install. IDEMPOTENT: never overwrite an existing
639
+ // file (the user may have customized it) — only create when missing.
640
+ if (usesOpencodeReviewer) {
641
+ const opencodeAgentPath = path.join(home, OPENCODE_AGENT_REL);
642
+ const agentAlreadyPresent = existsSync(opencodeAgentPath);
643
+ let agentContent = "";
644
+ if (!agentAlreadyPresent) {
645
+ // Read the bundled agent markdown once so a single missing-bundle error
646
+ // surfaces clearly instead of mid-write.
647
+ agentContent = await readFile(BUNDLED_OPENCODE_AGENT_PATH, "utf8");
648
+ }
649
+ plannedWrites.push({
650
+ path: opencodeAgentPath,
651
+ content: agentContent,
652
+ note: agentAlreadyPresent
653
+ ? "opencode read-only agent (adversarial-reviewer.md) — already present, will be kept"
654
+ : "opencode read-only agent (adversarial-reviewer.md) — mode:primary, read-only",
655
+ type: "opencode-agent",
656
+ // User-level shared agent: a normal-readable file -> 0o644.
657
+ mode: 0o644,
658
+ // Idempotency marker: when true the real-mode writer skips this entry.
659
+ skipExisting: agentAlreadyPresent,
660
+ });
661
+ }
662
+
428
663
  // --- Dry-run: print and exit without writing ---
429
664
 
430
665
  if (dryRun) {
431
666
  io.stdout.write("adversarial-review install --dry-run: planned writes\n");
432
667
  io.stdout.write("(No files will be written in dry-run mode)\n\n");
433
668
  for (const w of plannedWrites) {
434
- io.stdout.write(` [WRITE] ${w.path}\n`);
669
+ // Idempotent entries that already exist on disk are listed as SKIP so the
670
+ // dry-run accurately previews that the real run will keep the file.
671
+ const tag = w.skipExisting ? "SKIP " : "WRITE";
672
+ io.stdout.write(` [${tag}] ${w.path}\n`);
435
673
  io.stdout.write(` ${w.note}\n`);
436
674
  }
437
675
  if (wrapperInstructionsList.length) {
@@ -450,8 +688,15 @@ export async function installCommand(argv, io) {
450
688
  // --- Real mode: write files ---
451
689
 
452
690
  for (const w of plannedWrites) {
691
+ // FIX 2 idempotency: never overwrite an existing opencode agent (or any
692
+ // entry flagged skipExisting) — the user may have customized it.
693
+ if (w.skipExisting) {
694
+ io.stdout.write(`Keeping ${w.path} (already present) ...\n`);
695
+ io.stdout.write(` SKIP: ${w.note}\n`);
696
+ continue;
697
+ }
453
698
  io.stdout.write(`Writing ${w.path} ...\n`);
454
- await atomicWrite(w.path, w.content);
699
+ await atomicWrite(w.path, w.content, w.mode);
455
700
  io.stdout.write(` OK: ${w.note}\n`);
456
701
  }
457
702
 
@@ -465,6 +710,15 @@ export async function installCommand(argv, io) {
465
710
  }
466
711
  }
467
712
 
713
+ // FIX 3: when we fell back to npx, recommend a global install for lower
714
+ // per-hook latency (npx resolves the package on every Stop event).
715
+ if (!hookBin.direct) {
716
+ io.stdout.write(
717
+ "\nTip: install globally for lower per-hook latency: npm i -g adversarial-review-gate\n" +
718
+ " (the hook then runs `adversarial-review-gate` directly instead of resolving via npx).\n"
719
+ );
720
+ }
721
+
468
722
  io.stdout.write("\nadversarial-review install: complete.\n");
469
723
  process.exitCode = 0;
470
724
  }
@@ -474,16 +728,19 @@ export async function installCommand(argv, io) {
474
728
  // ---------------------------------------------------------------------------
475
729
 
476
730
  /**
477
- * Build the project config object to write. We include only the keys that
478
- * are meaningful for a project config (not DEFAULT_CONFIG boilerplate), plus
479
- * the computed hosts/reviewers from the install run. We always run through
731
+ * Build the config object to write. For PROJECT scope we include only the keys
732
+ * that are meaningful for a project config (not DEFAULT_CONFIG boilerplate),
733
+ * plus the computed hosts/reviewers from the install run. For USER scope
734
+ * (fullMachineConfig=true) we always emit policy.mode and the reviewers block so
735
+ * the machine-wide config is explicit and self-describing. We always run through
480
736
  * applyPolicyFloor to ensure we never loosen the user floor.
481
737
  *
482
738
  * @param {object} newProjectConfig - merged project + legacy + install config
483
739
  * @param {object} resolvedConfig - fully resolved config (post applyPolicyFloor)
740
+ * @param {boolean} [fullMachineConfig=false] - emit the full machine-wide config
484
741
  * @returns {object}
485
742
  */
486
- function buildProjectConfigToWrite(newProjectConfig, resolvedConfig) {
743
+ function buildProjectConfigToWrite(newProjectConfig, resolvedConfig, fullMachineConfig = false) {
487
744
  // Start from the project-level config (not DEFAULT_CONFIG) so we don't
488
745
  // flood the project file with defaults.
489
746
  const out = {
@@ -499,5 +756,13 @@ function buildProjectConfigToWrite(newProjectConfig, resolvedConfig) {
499
756
  if (newProjectConfig.reviewers) out.reviewers = resolvedConfig.reviewers;
500
757
  if (newProjectConfig.sensitivity) out.sensitivity = resolvedConfig.sensitivity;
501
758
 
759
+ // USER scope: the machine-wide config must be self-describing. Always emit
760
+ // policy.mode and the reviewers block even when no explicit override was given.
761
+ if (fullMachineConfig) {
762
+ if (!out.policy) out.policy = {};
763
+ if (out.policy.mode === undefined) out.policy.mode = resolvedConfig.policy.mode;
764
+ if (!out.reviewers) out.reviewers = resolvedConfig.reviewers || {};
765
+ }
766
+
502
767
  return out;
503
768
  }
package/src/cli/main.js CHANGED
@@ -1,4 +1,4 @@
1
- const COMMANDS = new Set(["install", "check", "hook", "run", "doctor", "help"]);
1
+ const COMMANDS = new Set(["install", "uninstall", "check", "hook", "run", "doctor", "help"]);
2
2
 
3
3
  export async function main(argv, io) {
4
4
  const [cmd = "help", ...rest] = argv;
@@ -28,6 +28,10 @@ export async function main(argv, io) {
28
28
  const { runCommand } = await import("./run.js");
29
29
  return runCommand(rest, io);
30
30
  }
31
+ if (cmd === "uninstall") {
32
+ const { uninstallCommand } = await import("./uninstall.js");
33
+ return uninstallCommand(rest, io);
34
+ }
31
35
  const { installCommand } = await import("./install.js");
32
36
  return installCommand(rest, io);
33
37
  }
@@ -38,6 +42,7 @@ export function helpText() {
38
42
  "",
39
43
  "Commands:",
40
44
  " install Install host integrations and project config",
45
+ " uninstall Remove our host hooks and registry entry (keeps shared agent)",
41
46
  " check Run the review gate on the current workspace",
42
47
  " hook Run as a native host lifecycle hook",
43
48
  " run Wrap a host tool command and gate after it exits",