adversarial-review-gate 2.0.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.
@@ -0,0 +1,59 @@
1
+ // Host -> reviewer mapping for the CLI entrypoints.
2
+ //
3
+ // The gate routes review work based on `host.reviewerMapping`:
4
+ // - "none" -> native self-review (the host's own agent runs the bundled
5
+ // self-review orchestrator; no external reviewer process);
6
+ // - <id> -> an external reviewer adapter (codex/opencode/custom) is spawned.
7
+ //
8
+ // Claude Code enforces natively via its Stop hook, so it always uses native
9
+ // self-review ("none"). Wrapper hosts (codex/opencode/...) map to an external
10
+ // reviewer; the project config's `hosts[<host>].reviewer` selects which one,
11
+ // defaulting to the host id itself when it names a known reviewer.
12
+
13
+ import { makeReviewerRunner } from "../reviewers/index.js";
14
+
15
+ const NATIVE_HOSTS = new Set(["claude-code"]);
16
+ const KNOWN_REVIEWERS = new Set(["codex", "opencode"]);
17
+
18
+ /**
19
+ * Resolve the reviewer mapping for a host given the effective config.
20
+ *
21
+ * @param {string} host
22
+ * @param {object} config
23
+ * @returns {string} reviewer id, or "none" for native self-review
24
+ */
25
+ export function reviewerMappingFor(host, config) {
26
+ // Explicit per-host config wins for every host, including native ones. This
27
+ // lets a native host (e.g. claude-code) opt into an external reviewer such as
28
+ // "opencode", or explicitly request native self-review with "none".
29
+ const configured = config?.hosts?.[host]?.reviewer;
30
+ if (typeof configured === "string" && configured.length) return configured;
31
+
32
+ // No explicit config: native hosts default to native self-review.
33
+ if (NATIVE_HOSTS.has(host)) return "none";
34
+
35
+ // A host id that itself names a known/custom reviewer maps to that reviewer.
36
+ if (KNOWN_REVIEWERS.has(host)) return host;
37
+ if (config?.reviewers?.[host]?.type === "custom") return host;
38
+
39
+ // Nothing mapped: native self-review.
40
+ return "none";
41
+ }
42
+
43
+ /**
44
+ * Build the `host` descriptor + (optional) reviewerRunner the gate expects.
45
+ *
46
+ * @param {string} host
47
+ * @param {object} config
48
+ * @param {object} env
49
+ * @returns {{ hostDescriptor: object, reviewerRunner: Function|null }}
50
+ */
51
+ export function buildHostRouting(host, config, env) {
52
+ const reviewerMapping = reviewerMappingFor(host, config);
53
+ const hostDescriptor = { id: host, reviewerMapping };
54
+ let reviewerRunner = null;
55
+ if (reviewerMapping !== "none") {
56
+ reviewerRunner = makeReviewerRunner(reviewerMapping, config, env);
57
+ }
58
+ return { hostDescriptor, reviewerRunner };
59
+ }
@@ -0,0 +1,503 @@
1
+ // `adversarial-review install` — multi-host installation and config writer.
2
+ //
3
+ // Non-interactive flags:
4
+ // --hosts a,b comma-separated list of hosts to install
5
+ // --reviewer host=reviewer reviewer mapping (repeatable)
6
+ // --dry-run print planned writes; write nothing
7
+ // --project-config <path> path to an explicit project config file
8
+ //
9
+ // Validation rules (reject with non-zero exit + clear error):
10
+ // 1. A host mapped to ITSELF as reviewer.
11
+ // 2. A selected host with NO reviewer mapping (must choose a reviewer or "none").
12
+ // 3. A reviewer mapping value that is empty/whitespace (distinct from "none").
13
+ // 4. A reviewer that is unavailable (verify() fails) and is NOT "none".
14
+ // 5. A host whose enforcement is literally "advisory" when policy disallows advisory hosts.
15
+ // "wrapper-enforced" is DISTINCT from "advisory" and is allowed by default; a
16
+ // wrapper-enforced host emits an informational disclosure note but is not rejected.
17
+ //
18
+ // In dry-run mode: print every planned write path and its note, then exit 0
19
+ // WITHOUT writing any files.
20
+
21
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
22
+ import { existsSync } from "node:fs";
23
+ import path from "node:path";
24
+ import os from "node:os";
25
+
26
+ import { mergeConfig, applyPolicyFloor, DEFAULT_CONFIG } from "../core/config.js";
27
+ import { HOSTS } from "../hosts/index.js";
28
+ import { plannedClaudeCodeWrites } from "../hosts/claude-code.js";
29
+ import { wrapperInstructions } from "../hosts/wrapper.js";
30
+ import { createReviewer } from "../reviewers/index.js";
31
+
32
+ // Path constants (relative to cwd / home).
33
+ const PROJECT_CONFIG_REL = path.join(".adversarial-review", "config.json");
34
+ const USER_POLICY_REL = path.join(".adversarial-review", "policy.json");
35
+ const USER_INSTALL_REL = path.join(".adversarial-review", "install.json");
36
+ const LEGACY_CONFIG_REL = path.join("hooks", "config.json");
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Argument parsing
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Parse the install command's argv array into structured options.
44
+ *
45
+ * @param {string[]} argv
46
+ * @returns {{ hosts: string[], reviewerMap: Map<string,string>, dryRun: boolean, projectConfigPath: string|null }}
47
+ */
48
+ function parseArgs(argv) {
49
+ const hosts = [];
50
+ const reviewerMap = new Map();
51
+ let dryRun = false;
52
+ let projectConfigPath = null;
53
+
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const arg = argv[i];
56
+ if (arg === "--dry-run") {
57
+ dryRun = true;
58
+ } else if (arg === "--hosts" && argv[i + 1]) {
59
+ // Accept either `--hosts a,b` or `--hosts a --hosts b`.
60
+ argv[i + 1].split(",").forEach((h) => hosts.push(h.trim()));
61
+ i++;
62
+ } else if (arg.startsWith("--hosts=")) {
63
+ arg.slice("--hosts=".length).split(",").forEach((h) => hosts.push(h.trim()));
64
+ } else if (arg === "--reviewer" && argv[i + 1]) {
65
+ const pair = argv[i + 1];
66
+ const eq = pair.indexOf("=");
67
+ if (eq > 0) {
68
+ reviewerMap.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
69
+ }
70
+ i++;
71
+ } else if (arg === "--project-config" && argv[i + 1]) {
72
+ projectConfigPath = argv[i + 1];
73
+ i++;
74
+ }
75
+ }
76
+
77
+ return { hosts, reviewerMap, dryRun, projectConfigPath };
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Config I/O helpers
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /** Tolerantly read and parse a JSON file; returns {} on any error. */
85
+ async function readJsonTolerant(filePath) {
86
+ try {
87
+ const raw = await readFile(filePath, "utf8");
88
+ const parsed = JSON.parse(raw);
89
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
90
+ return {};
91
+ } catch {
92
+ return {};
93
+ }
94
+ }
95
+
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;
101
+ }
102
+ return os.homedir();
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Legacy config migration
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Read a legacy `hooks/config.json` (Python-era format) and translate it into
111
+ * the current config schema. Returns `{}` when no legacy file exists.
112
+ *
113
+ * Legacy mappings:
114
+ * thresholds.* (bigDiffLines, bigFileCount, ...) -> thresholds.*
115
+ * engine: "opencode" | "codex" -> hosts["claude-code"].reviewer
116
+ * timeout (top-level or reviewers.*.timeout) -> runtime.timeoutSec
117
+ *
118
+ * @param {string} cwd
119
+ * @returns {Promise<object>} partial config fragment (empty if no legacy file)
120
+ */
121
+ async function readLegacyConfig(cwd) {
122
+ const legacyPath = path.join(cwd, LEGACY_CONFIG_REL);
123
+ const legacy = await readJsonTolerant(legacyPath);
124
+ if (!Object.keys(legacy).length) return {};
125
+
126
+ const migrated = {};
127
+
128
+ // Migrate threshold keys.
129
+ const THRESHOLD_KEYS = new Set([
130
+ "bigDiffLines",
131
+ "bigFileCount",
132
+ "debateDiffLines",
133
+ "debateFileCount",
134
+ "debateOnSensitive",
135
+ ]);
136
+ const thresholds = {};
137
+ for (const [key, value] of Object.entries(legacy)) {
138
+ if (THRESHOLD_KEYS.has(key)) thresholds[key] = value;
139
+ }
140
+ if (Object.keys(thresholds).length) migrated.thresholds = thresholds;
141
+
142
+ // Migrate engine -> hosts["claude-code"].reviewer.
143
+ if (typeof legacy.engine === "string" && legacy.engine) {
144
+ migrated.hosts = {
145
+ "claude-code": { reviewer: legacy.engine },
146
+ };
147
+ }
148
+
149
+ // Migrate timeout -> runtime.timeoutSec.
150
+ const timeout =
151
+ legacy.timeout ||
152
+ legacy.reviewers?.opencode?.timeout ||
153
+ legacy.reviewers?.codex?.timeout;
154
+ if (typeof timeout === "number" && timeout > 0) {
155
+ migrated.runtime = { timeoutSec: timeout };
156
+ }
157
+
158
+ return migrated;
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Reviewer availability check
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Verify that a reviewer id is available (its binary resolves on PATH).
167
+ * "none" is always treated as available.
168
+ *
169
+ * @param {string} reviewerId
170
+ * @param {object} config - effective config (used by createReviewer)
171
+ * @param {object} env - environment variables
172
+ * @returns {Promise<{ok: boolean, reason?: string}>}
173
+ */
174
+ async function checkReviewerAvailability(reviewerId, config, env) {
175
+ if (reviewerId === "none") return { ok: true };
176
+ try {
177
+ const adapter = createReviewer(reviewerId, config);
178
+ return adapter.verify(env);
179
+ } catch (err) {
180
+ return { ok: false, reason: err.message };
181
+ }
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Atomic write helper
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /**
189
+ * Write `content` to `filePath`, creating parent directories as needed.
190
+ * Writes atomically by writing to a temp file and renaming (best-effort on
191
+ * Windows where rename semantics differ; we do a two-step write+rename).
192
+ *
193
+ * @param {string} filePath
194
+ * @param {string} content
195
+ */
196
+ async function atomicWrite(filePath, content) {
197
+ const dir = path.dirname(filePath);
198
+ await mkdir(dir, { recursive: true });
199
+ const tmp = `${filePath}.tmp${Date.now()}`;
200
+ await writeFile(tmp, content, { encoding: "utf8", mode: 0o600 });
201
+ // node:fs rename is atomic on POSIX; on Windows it will overwrite on Node 14+.
202
+ const { rename } = await import("node:fs/promises");
203
+ await rename(tmp, filePath);
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Main install command
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /**
211
+ * @param {string[]} argv
212
+ * @param {object} io - { stdin, stdout, stderr, env, cwd }
213
+ */
214
+ export async function installCommand(argv, io) {
215
+ const cwd = io.cwd || process.cwd();
216
+ const env = io.env || process.env;
217
+ const home = homeDir(env);
218
+
219
+ const { hosts, reviewerMap, dryRun, projectConfigPath } = parseArgs(argv);
220
+
221
+ // --- Require at least one host ---
222
+ if (!hosts.length) {
223
+ io.stderr.write(
224
+ "adversarial-review install: no hosts specified.\n" +
225
+ "Usage: adversarial-review install --hosts <host,...> --reviewer <host=reviewer> [--dry-run]\n"
226
+ );
227
+ process.exitCode = 2;
228
+ return;
229
+ }
230
+
231
+ // --- Load user policy floor ---
232
+ const userPolicyPath = path.join(home, USER_POLICY_REL);
233
+ const userPolicyFloor = await readJsonTolerant(userPolicyPath);
234
+
235
+ // --- Load existing project config (from explicit path or default location) ---
236
+ const projectConfigPath2 = projectConfigPath || path.join(cwd, PROJECT_CONFIG_REL);
237
+ const existingProjectConfig = await readJsonTolerant(projectConfigPath2);
238
+
239
+ // --- Read legacy config and merge ---
240
+ const legacyFragment = await readLegacyConfig(cwd);
241
+
242
+ // Build initial project config by layering: DEFAULT_CONFIG <- legacy <- existing.
243
+ // We do NOT write legacy values if the existing config already has them.
244
+ const baseProjectConfig = Object.assign({}, legacyFragment, existingProjectConfig);
245
+
246
+ // Build the effective config to evaluate advisory/policy constraints.
247
+ const effectiveConfig = mergeConfig(baseProjectConfig, userPolicyFloor);
248
+
249
+ // --- Validate hosts and reviewer mappings ---
250
+
251
+ // 1. All selected hosts must be known.
252
+ for (const host of hosts) {
253
+ if (!HOSTS[host]) {
254
+ io.stderr.write(
255
+ `adversarial-review install: unknown host "${host}". ` +
256
+ `Known hosts: ${Object.keys(HOSTS).join(", ")}\n`
257
+ );
258
+ process.exitCode = 2;
259
+ return;
260
+ }
261
+ }
262
+
263
+ // 2. Each host must have a reviewer mapping.
264
+ for (const host of hosts) {
265
+ if (!reviewerMap.has(host)) {
266
+ io.stderr.write(
267
+ `adversarial-review install: host "${host}" has no reviewer mapping.\n` +
268
+ `Specify --reviewer ${host}=<reviewer|none>.\n`
269
+ );
270
+ process.exitCode = 2;
271
+ return;
272
+ }
273
+ }
274
+
275
+ // 3. No host may map to itself as reviewer.
276
+ for (const [host, reviewer] of reviewerMap) {
277
+ if (hosts.includes(host) && reviewer === host) {
278
+ io.stderr.write(
279
+ `adversarial-review install: host "${host}" cannot be mapped to itself as reviewer.\n`
280
+ );
281
+ process.exitCode = 2;
282
+ return;
283
+ }
284
+ }
285
+
286
+ // 3b. BONUS: Warn about reviewer mappings for hosts not in the selected --hosts list.
287
+ // These mappings are silently ignored otherwise; surface a clear warning.
288
+ for (const [mappedHost] of reviewerMap) {
289
+ if (!hosts.includes(mappedHost)) {
290
+ io.stderr.write(
291
+ `adversarial-review install: WARNING: --reviewer mapping for "${mappedHost}" is ignored ` +
292
+ `because "${mappedHost}" is not in the selected --hosts list.\n`
293
+ );
294
+ }
295
+ }
296
+
297
+ // 4. Reviewer availability (skip "none"; skip unknown hosts not in selected list).
298
+ for (const host of hosts) {
299
+ const reviewer = reviewerMap.get(host);
300
+ // Empty/whitespace reviewer value is invalid — distinguish from the legitimate "none".
301
+ if (typeof reviewer === "string" && reviewer.trim() === "") {
302
+ io.stderr.write(
303
+ `adversarial-review install: reviewer mapping for "${host}" is empty; ` +
304
+ `specify a reviewer tool or 'none'.\n`
305
+ );
306
+ process.exitCode = 2;
307
+ return;
308
+ }
309
+ if (reviewer === "none") continue;
310
+ const result = await checkReviewerAvailability(reviewer, effectiveConfig, env);
311
+ if (!result.ok) {
312
+ io.stderr.write(
313
+ `adversarial-review install: reviewer "${reviewer}" for host "${host}" is not available.\n` +
314
+ ` Reason: ${result.reason || "missing_binary"}\n` +
315
+ ` Install the reviewer or use --reviewer ${host}=none.\n`
316
+ );
317
+ process.exitCode = 2;
318
+ return;
319
+ }
320
+ }
321
+
322
+ // 5. Advisory host check: only reject hosts whose enforcement is literally "advisory".
323
+ // "wrapper-enforced" is a DISTINCT level from "advisory" — wrapper hosts have reliable
324
+ // blocking via the wrapper command and must install by default. allowAdvisoryHosts only
325
+ // gates hosts with enforcement === "advisory". (Currently no HOSTS entries are advisory.)
326
+ // For wrapper-enforced hosts, emit an informational disclosure note (in both dry-run
327
+ // and real mode) disclosing residual risk — never present wrapper as equal to native.
328
+ const allowAdvisory = effectiveConfig.policy?.allowAdvisoryHosts !== false;
329
+ if (!allowAdvisory) {
330
+ for (const host of hosts) {
331
+ const hostInfo = HOSTS[host];
332
+ if (hostInfo.enforcement === "advisory") {
333
+ // Hard rejection in both dry-run and real mode — policy disallows advisory hosts.
334
+ io.stderr.write(
335
+ `adversarial-review install: host "${host}" has advisory enforcement ` +
336
+ `but the effective policy has allowAdvisoryHosts:false.\n` +
337
+ `Set allowAdvisoryHosts:true in your policy or choose a native-enforced host.\n`
338
+ );
339
+ process.exitCode = 2;
340
+ return;
341
+ }
342
+ }
343
+ }
344
+
345
+ // --- Build the new project config ---
346
+
347
+ // Populate hosts and reviewers sections from the mapping.
348
+ const hostsConfig = { ...(baseProjectConfig.hosts || {}) };
349
+ for (const host of hosts) {
350
+ hostsConfig[host] = {
351
+ ...(hostsConfig[host] || {}),
352
+ reviewer: reviewerMap.get(host),
353
+ };
354
+ }
355
+
356
+ const newProjectConfig = {
357
+ ...baseProjectConfig,
358
+ hosts: hostsConfig,
359
+ };
360
+
361
+ // Merge with DEFAULT_CONFIG and enforce policy floor.
362
+ const resolvedConfig = mergeConfig(newProjectConfig, userPolicyFloor);
363
+
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);
367
+ const configJson = JSON.stringify(configToWrite, null, 2);
368
+
369
+ // --- Collect planned writes ---
370
+
371
+ const plannedWrites = [];
372
+
373
+ // 1. Project config.
374
+ const projectConfigOutPath = path.join(cwd, PROJECT_CONFIG_REL);
375
+ plannedWrites.push({
376
+ path: projectConfigOutPath,
377
+ content: configJson,
378
+ note: "Project config (.adversarial-review/config.json)",
379
+ type: "project-config",
380
+ });
381
+
382
+ // 2. User-level install registry.
383
+ const installRegistryPath = path.join(home, USER_INSTALL_REL);
384
+ const existingRegistry = await readJsonTolerant(installRegistryPath);
385
+ const registryEntry = {
386
+ installedAt: new Date().toISOString(),
387
+ hosts,
388
+ reviewers: Object.fromEntries(reviewerMap),
389
+ };
390
+ const updatedRegistry = {
391
+ ...existingRegistry,
392
+ [cwd]: registryEntry,
393
+ };
394
+ plannedWrites.push({
395
+ path: installRegistryPath,
396
+ content: JSON.stringify(updatedRegistry, null, 2),
397
+ note: "User-level install registry (~/.adversarial-review/install.json)",
398
+ type: "install-registry",
399
+ });
400
+
401
+ // 3. Per-host integration files (native) or wrapper instructions.
402
+ const wrapperInstructionsList = [];
403
+ for (const host of hosts) {
404
+ const hostInfo = HOSTS[host];
405
+ if (hostInfo.enforcement === "native-enforced") {
406
+ // Native host: compute planned file writes.
407
+ if (host === "claude-code") {
408
+ const nativeWrites = plannedClaudeCodeWrites({ cwd });
409
+ for (const w of nativeWrites) {
410
+ plannedWrites.push({
411
+ path: w.path,
412
+ content: w.content,
413
+ note: w.note,
414
+ type: "native-hook",
415
+ });
416
+ }
417
+ }
418
+ } else {
419
+ // Wrapper host: collect printable instructions (no file writes).
420
+ const instructions = wrapperInstructions({
421
+ host,
422
+ reviewer: reviewerMap.get(host),
423
+ });
424
+ wrapperInstructionsList.push(instructions);
425
+ }
426
+ }
427
+
428
+ // --- Dry-run: print and exit without writing ---
429
+
430
+ if (dryRun) {
431
+ io.stdout.write("adversarial-review install --dry-run: planned writes\n");
432
+ io.stdout.write("(No files will be written in dry-run mode)\n\n");
433
+ for (const w of plannedWrites) {
434
+ io.stdout.write(` [WRITE] ${w.path}\n`);
435
+ io.stdout.write(` ${w.note}\n`);
436
+ }
437
+ if (wrapperInstructionsList.length) {
438
+ io.stdout.write("\nWrapper-host instructions (no files written):\n");
439
+ for (const inst of wrapperInstructionsList) {
440
+ io.stdout.write(`\n [WRAPPER] ${inst.host}\n`);
441
+ io.stdout.write(` Command: ${inst.wrapperCommand}\n`);
442
+ io.stdout.write(` Enforcement: ${inst.enforcement}\n`);
443
+ io.stdout.write(` Residual risk: ${inst.residualRisk}\n`);
444
+ }
445
+ }
446
+ process.exitCode = 0;
447
+ return;
448
+ }
449
+
450
+ // --- Real mode: write files ---
451
+
452
+ for (const w of plannedWrites) {
453
+ io.stdout.write(`Writing ${w.path} ...\n`);
454
+ await atomicWrite(w.path, w.content);
455
+ io.stdout.write(` OK: ${w.note}\n`);
456
+ }
457
+
458
+ if (wrapperInstructionsList.length) {
459
+ io.stdout.write("\nWrapper-host instructions (no files written):\n");
460
+ for (const inst of wrapperInstructionsList) {
461
+ io.stdout.write(`\n [WRAPPER] ${inst.host}\n`);
462
+ io.stdout.write(` Command: ${inst.wrapperCommand}\n`);
463
+ io.stdout.write(` Enforcement: ${inst.enforcement}\n`);
464
+ io.stdout.write(` Residual risk: ${inst.residualRisk}\n`);
465
+ }
466
+ }
467
+
468
+ io.stdout.write("\nadversarial-review install: complete.\n");
469
+ process.exitCode = 0;
470
+ }
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // Helper: build the project config object to serialize to disk.
474
+ // ---------------------------------------------------------------------------
475
+
476
+ /**
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
480
+ * applyPolicyFloor to ensure we never loosen the user floor.
481
+ *
482
+ * @param {object} newProjectConfig - merged project + legacy + install config
483
+ * @param {object} resolvedConfig - fully resolved config (post applyPolicyFloor)
484
+ * @returns {object}
485
+ */
486
+ function buildProjectConfigToWrite(newProjectConfig, resolvedConfig) {
487
+ // Start from the project-level config (not DEFAULT_CONFIG) so we don't
488
+ // flood the project file with defaults.
489
+ const out = {
490
+ version: resolvedConfig.version,
491
+ hosts: resolvedConfig.hosts,
492
+ };
493
+
494
+ // Carry over any explicit policy/threshold/runtime/privacy overrides.
495
+ if (newProjectConfig.policy) out.policy = resolvedConfig.policy;
496
+ if (newProjectConfig.thresholds) out.thresholds = resolvedConfig.thresholds;
497
+ if (newProjectConfig.runtime) out.runtime = resolvedConfig.runtime;
498
+ if (newProjectConfig.privacy) out.privacy = resolvedConfig.privacy;
499
+ if (newProjectConfig.reviewers) out.reviewers = resolvedConfig.reviewers;
500
+ if (newProjectConfig.sensitivity) out.sensitivity = resolvedConfig.sensitivity;
501
+
502
+ return out;
503
+ }
@@ -0,0 +1,48 @@
1
+ const COMMANDS = new Set(["install", "check", "hook", "run", "doctor", "help"]);
2
+
3
+ export async function main(argv, io) {
4
+ const [cmd = "help", ...rest] = argv;
5
+ if (!COMMANDS.has(cmd)) {
6
+ io.stderr.write(`Unknown command: ${cmd}\n`);
7
+ io.stderr.write(helpText());
8
+ process.exitCode = 2;
9
+ return;
10
+ }
11
+ if (cmd === "help") {
12
+ io.stdout.write(helpText());
13
+ return;
14
+ }
15
+ if (cmd === "doctor") {
16
+ const { doctorCommand } = await import("./doctor.js");
17
+ return doctorCommand(rest, io);
18
+ }
19
+ if (cmd === "check") {
20
+ const { checkCommand } = await import("./check.js");
21
+ return checkCommand(rest, io);
22
+ }
23
+ if (cmd === "hook") {
24
+ const { hookCommand } = await import("./hook.js");
25
+ return hookCommand(rest, io);
26
+ }
27
+ if (cmd === "run") {
28
+ const { runCommand } = await import("./run.js");
29
+ return runCommand(rest, io);
30
+ }
31
+ const { installCommand } = await import("./install.js");
32
+ return installCommand(rest, io);
33
+ }
34
+
35
+ export function helpText() {
36
+ return [
37
+ "Usage: adversarial-review <command> [options]",
38
+ "",
39
+ "Commands:",
40
+ " install Install host integrations and project config",
41
+ " check Run the review gate on the current workspace",
42
+ " hook Run as a native host lifecycle hook",
43
+ " run Wrap a host tool command and gate after it exits",
44
+ " doctor Diagnose config, host integrations, and reviewers",
45
+ " help Show this help",
46
+ "",
47
+ ].join("\n");
48
+ }