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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +78 -0
- package/README.md +152 -45
- package/package.json +7 -2
- package/src/cli/doctor.js +108 -11
- package/src/cli/install.js +300 -35
- package/src/cli/main.js +6 -1
- package/src/cli/uninstall.js +204 -0
- package/src/core/gate.js +91 -11
- package/src/core/load-config.js +18 -9
- package/src/core/process.js +0 -19
- package/src/core/transcript.js +12 -45
- package/src/core/verdict.js +31 -4
- package/src/hosts/claude-code.js +221 -19
- package/src/hosts/index.js +2 -1
- package/src/integrations/opencode/adversarial-reviewer.agent.md +142 -0
- package/src/reviewers/_shared.js +146 -0
- package/src/reviewers/codex.js +59 -99
- package/src/reviewers/custom.js +58 -96
- package/src/reviewers/index.js +5 -3
- package/src/reviewers/opencode.js +126 -162
package/src/cli/install.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
236
|
-
|
|
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
|
|
365
|
-
//
|
|
366
|
-
|
|
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
|
|
374
|
-
|
|
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:
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
478
|
-
* are meaningful for a project config (not DEFAULT_CONFIG boilerplate),
|
|
479
|
-
* the computed hosts/reviewers from the install run.
|
|
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",
|