arkgate 3.8.3 → 3.9.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.
@@ -21,11 +21,15 @@ import {
21
21
  } from './codex-home.mjs';
22
22
  import {
23
23
  PREFERRED_MCP_BIN,
24
+ antigravityHooks,
24
25
  claudeSettings,
25
26
  codexHooks,
26
27
  codexProjectConfig,
27
28
  grokHooks,
28
29
  grokProjectConfig,
30
+ mergeAntigravityArkHook,
31
+ mergeOpencodeArkMcp,
32
+ opencodeProjectConfig,
29
33
  } from './hook-templates.mjs';
30
34
  import {
31
35
  hasCheckArchitectureScript,
@@ -60,6 +64,7 @@ import {
60
64
  import { detectDeployPathQuality } from './deploy-path.mjs';
61
65
  import {
62
66
  stripMcpServerArgs,
67
+ stripOpencodeMcpCommand,
63
68
  COMMAND_GATE_TEXT_FILES,
64
69
  COMMAND_GATE_JSON_FILES,
65
70
  PREFERRED_CHECK_BIN,
@@ -175,6 +180,14 @@ export function buildManagedAssetCatalog({ root, tools, compact = false, skillsO
175
180
  add('.grok/config.toml', grokProjectConfig(root));
176
181
  add('.grok/hooks/ark-write-gate.json', grokHooks(root));
177
182
  }
183
+ if (selectedTools.has('antigravity')) {
184
+ add('.agents/hooks.json', antigravityHooks(root));
185
+ // Still useful for Gemini CLI / legacy Gemini consumers sharing the tree.
186
+ add('GEMINI.md', instructionRule(root));
187
+ }
188
+ if (selectedTools.has('opencode')) {
189
+ add('opencode.json', opencodeProjectConfig(root), 'gate', 'json-merge');
190
+ }
178
191
  if (selectedTools.has('windsurf')) add('.windsurf/rules/ark.md', instructionRule(root));
179
192
  if (selectedTools.has('cline')) add('.clinerules/ark.md', instructionRule(root));
180
193
  if (selectedTools.has('copilot')) {
@@ -238,6 +251,27 @@ export function runMigrateCommands(root) {
238
251
  } catch {
239
252
  continue;
240
253
  }
254
+ // OpenCode: mcp.ark.command is a single argv array (type: local), not mcpServers.ark.args.
255
+ if (rel === 'opencode.json') {
256
+ const ark = json?.mcp?.ark;
257
+ if (!ark || typeof ark !== 'object' || !Array.isArray(ark.command)) continue;
258
+ const argv = ark.command.filter((entry) => typeof entry === 'string');
259
+ if (argv.length === 0) continue;
260
+ // Drop runners + any ark* bin names; keep only server flags (e.g. --root .).
261
+ const binArgs = stripOpencodeMcpCommand(argv);
262
+ const parts = execCommandParts(root, PREFERRED_MCP_BIN, binArgs);
263
+ const preferredArgv = [parts.command, ...parts.args];
264
+ if (JSON.stringify(argv) === JSON.stringify(preferredArgv)) continue;
265
+ json.mcp.ark = {
266
+ ...ark,
267
+ type: ark.type ?? 'local',
268
+ command: preferredArgv,
269
+ enabled: ark.enabled !== false,
270
+ };
271
+ fs.writeFileSync(full, `${JSON.stringify(json, null, 2)}\n`);
272
+ changed.push(rel);
273
+ continue;
274
+ }
241
275
  const ark = json?.mcpServers?.ark;
242
276
  if (!ark) continue;
243
277
  const binArgs = stripMcpServerArgs(ark.args);
@@ -316,6 +350,14 @@ export function runInstallAgentGates(args) {
316
350
  ? 'no active host detected'
317
351
  : 'default set — no agent config dirs found';
318
352
  console.log(`Agent gates for: ${[...tools].sort().join(', ')} (${toolSource})`);
353
+ // Progressive disclosure (3.9.0): compact = router; skills-only = expert pack.
354
+ if (!args.json) {
355
+ const host = [...tools][0];
356
+ const later = host ? ` --tools ${host}` : '';
357
+ if (args.compact) console.log(`Profile: compact router. Expert skills later: ${arkCommand(root, 'ark-check', `--install-agent-gates --skills-only${later} --force`)}`);
358
+ else if (args.skillsOnly) console.log('Profile: expert skill pack only (refreshes /ark-*; leaves customized gates).');
359
+ else console.log('Profile: full agent gates. Compact-only onboarding: ark start.');
360
+ }
319
361
  // --skills-only refreshes just the canonical /ark-* skills, which are safe to
320
362
  // overwrite (they track the package). The gate/instruction files (AGENTS.md,
321
363
  // settings.json, CI workflow, rules) are the ones users customize, so a plain
@@ -367,6 +409,43 @@ export function runInstallAgentGates(args) {
367
409
  if (merged === existing) return { relativePath, status: 'skipped' };
368
410
  return writeTemplate(root, relativePath, merged, true);
369
411
  }
412
+ if (relativePath === 'opencode.json') {
413
+ const fullPath = path.join(root, relativePath);
414
+ let existing = '';
415
+ try {
416
+ existing = fs.readFileSync(fullPath, 'utf8');
417
+ } catch {
418
+ // Missing project config → write the generated Ark MCP block.
419
+ }
420
+ if (!existing) {
421
+ return writeTemplate(root, relativePath, content, true);
422
+ }
423
+ const merged = mergeOpencodeArkMcp(existing, content);
424
+ if (merged == null) {
425
+ return { relativePath, status: 'skipped-non-ark' };
426
+ }
427
+ if (merged === existing) return { relativePath, status: 'skipped' };
428
+ return writeTemplate(root, relativePath, merged, true);
429
+ }
430
+ if (relativePath === '.agents/hooks.json') {
431
+ const fullPath = path.join(root, relativePath);
432
+ let existing = '';
433
+ try {
434
+ existing = fs.readFileSync(fullPath, 'utf8');
435
+ } catch {
436
+ // Missing hooks file → write generated ark-write-gate map.
437
+ }
438
+ if (!existing) {
439
+ return writeTemplate(root, relativePath, content, true);
440
+ }
441
+ const merged = mergeAntigravityArkHook(existing, content);
442
+ if (merged == null) {
443
+ return { relativePath, status: 'skipped-non-ark' };
444
+ }
445
+ if (merged === existing) return { relativePath, status: 'skipped' };
446
+ // Upsert ark-write-gate without requiring --force; never wipe sibling named hooks.
447
+ return writeTemplate(root, relativePath, merged, true);
448
+ }
370
449
  return writeTemplate(
371
450
  root,
372
451
  relativePath,
@@ -516,6 +595,19 @@ export function runInstallAgentGates(args) {
516
595
  console.log(' 3. Add the package.json alias if you want `run check:architecture`:');
517
596
  console.log(` ${checkArchitectureScriptSnippet(root)}`);
518
597
  }
598
+ if (tools.has('antigravity') && !args.compact) {
599
+ console.log('');
600
+ console.log(' Antigravity: PreToolUse deny is hard for listed write tools when hooks are trusted.');
601
+ console.log(' - Install path: `.agents/hooks.json` (+ GEMINI.md for legacy Gemini consumers).');
602
+ console.log(' - Trust project hooks in the host; pair with required CI --strict-merge.');
603
+ }
604
+ if (tools.has('opencode') && !args.compact) {
605
+ console.log('');
606
+ console.log(' OpenCode write path (honest):');
607
+ console.log(' - Local: advisory MCP in opencode.json (optional experimental plugin only).');
608
+ console.log(' - Hard merge backstop: CI --strict-merge + required status check.');
609
+ console.log(' - Not equivalent to Claude/Grok/Antigravity PreToolUse hard-write.');
610
+ }
519
611
  if ((tools.has('codex') || args.codexHome)) {
520
612
  console.log('');
521
613
  if (codexMcp && codexMcp.status !== 'failed') {
@@ -110,6 +110,8 @@ const HOST_SIGNALS = {
110
110
  cursor: ['.cursor/mcp.json', '.cursor/rules/ark.mdc', '.cursor/commands/ark-upgrade.md'],
111
111
  codex: ['.codex/hooks.json', '.codex/config.toml', '.agents/skills/ark-upgrade/SKILL.md'],
112
112
  grok: ['.grok/config.toml', '.grok/hooks/ark-write-gate.json', '.grok/skills/ark-upgrade/SKILL.md'],
113
+ antigravity: ['.agents/hooks.json', '.agents/skills/ark-upgrade/SKILL.md'],
114
+ opencode: ['opencode.json', '.opencode/skills/ark-upgrade/SKILL.md'],
113
115
  windsurf: ['.windsurf/rules/ark.md', '.windsurf/workflows/ark-upgrade.md'],
114
116
  cline: ['.clinerules/ark.md', '.clinerules/workflows/ark-upgrade.md'],
115
117
  copilot: ['.github/copilot-instructions.md', '.github/prompts/ark-upgrade.prompt.md'],
@@ -25,8 +25,9 @@ export const COMMAND_GATE_TEXT_FILES = [
25
25
  '.clinerules/ark.md', '.github/copilot-instructions.md', '.kiro/steering/ark.md',
26
26
  '.roo/rules/ark.md', '.continue/rules/ark.md', 'GEMINI.md', 'package.json',
27
27
  '.grok/hooks/ark-write-gate.json', '.grok/config.toml', '.codex/config.toml',
28
+ '.agents/hooks.json',
28
29
  ];
29
- export const COMMAND_GATE_JSON_FILES = ['.mcp.json', '.cursor/mcp.json'];
30
+ export const COMMAND_GATE_JSON_FILES = ['.mcp.json', '.cursor/mcp.json', 'opencode.json'];
30
31
  // Primary CLI names (product) + one-major aliases. migrate-commands must strip ALL of these
31
32
  // before re-emitting a single preferred bin — otherwise a partial rename leaves
32
33
  // args: ["ark-mcp", "arkgate-mcp", ...] which breaks stdio MCP hosts.
@@ -61,6 +62,34 @@ export function stripMcpServerArgs(args) {
61
62
  return kept.length > 0 ? kept : ['--root', '.', '--config', 'ark.config.json'];
62
63
  }
63
64
 
65
+ /**
66
+ * OpenCode `mcp.ark.command` is a full argv (runner + bin + flags).
67
+ * Strip runners and any ark* bin names so migrate can re-emit preferred command+args.
68
+ */
69
+ export function stripOpencodeMcpCommand(command) {
70
+ if (!Array.isArray(command) || command.length === 0) {
71
+ return ['--root', '.', '--config', 'ark.config.json'];
72
+ }
73
+ const runners = new Set(['npx', 'yarn', 'pnpm', 'node', 'bun']);
74
+ const kept = [];
75
+ for (const entry of command) {
76
+ if (typeof entry !== 'string') continue;
77
+ const base = path.basename(entry.replace(/\\/g, '/'));
78
+ if (
79
+ runners.has(base) ||
80
+ MCP_RUNNER_ARGV.has(entry) ||
81
+ ARK_MCP_BINS.has(base) ||
82
+ ARK_MCP_BINS.has(entry) ||
83
+ ARK_CHECK_BINS.has(base) ||
84
+ ARK_CLI_BINS.has(base)
85
+ ) {
86
+ continue;
87
+ }
88
+ kept.push(entry);
89
+ }
90
+ return kept.length > 0 ? kept : ['--root', '.', '--config', 'ark.config.json'];
91
+ }
92
+
64
93
  /** True when mcpServers.ark.args list more than one Ark MCP bin (broken dual rename). */
65
94
  export function mcpArgsHaveDuplicateBins(args) {
66
95
  if (!Array.isArray(args)) return false;
@@ -77,6 +106,12 @@ export function brokenMcpGateFiles(root) {
77
106
  } catch {
78
107
  continue;
79
108
  }
109
+ // OpenCode uses mcp.ark.command[] (single argv); Claude/Cursor use mcpServers.ark.args.
110
+ if (rel === 'opencode.json') {
111
+ const command = json?.mcp?.ark?.command;
112
+ if (Array.isArray(command) && mcpArgsHaveDuplicateBins(command)) bad.push(rel);
113
+ continue;
114
+ }
80
115
  const ark = json?.mcpServers?.ark;
81
116
  if (ark && mcpArgsHaveDuplicateBins(ark.args)) bad.push(rel);
82
117
  }
@@ -159,9 +194,32 @@ export function collectAdoptionGaps(root, config, coverage) {
159
194
  ],
160
195
  toolsFlag: 'codex',
161
196
  },
197
+ {
198
+ host: 'antigravity',
199
+ dir: '.agents',
200
+ skill: (n) => path.join(root, '.agents', 'skills', n, 'SKILL.md'),
201
+ extras: [['.agents/hooks.json', 'write-gate hook']],
202
+ toolsFlag: 'antigravity',
203
+ // Only when hooks.json is present — `.agents/skills` alone is Codex scope.
204
+ presentIf: () => fs.existsSync(path.join(root, '.agents', 'hooks.json')),
205
+ },
206
+ {
207
+ host: 'opencode',
208
+ dir: '.opencode',
209
+ skill: (n) => path.join(root, '.opencode', 'skills', n, 'SKILL.md'),
210
+ extras: [['opencode.json', 'project MCP config']],
211
+ toolsFlag: 'opencode',
212
+ presentIf: () =>
213
+ fs.existsSync(path.join(root, 'opencode.json')) ||
214
+ fs.existsSync(path.join(root, '.opencode')),
215
+ },
162
216
  ];
163
217
  for (const h of hostChecks) {
164
- if (!fs.existsSync(path.join(root, h.dir))) continue;
218
+ const present =
219
+ typeof h.presentIf === 'function'
220
+ ? h.presentIf()
221
+ : fs.existsSync(path.join(root, h.dir));
222
+ if (!present) continue;
165
223
  const missingSkills = skillNames.filter((n) => !fs.existsSync(h.skill(n)));
166
224
  const missingExtras = h.extras.filter(([rel]) => !fs.existsSync(path.join(root, rel)));
167
225
  const complete = missingSkills.length === 0 && missingExtras.length === 0;
@@ -17,11 +17,11 @@ export const POST_GREEN_PRIMARY_SKILL = '/ark-explore';
17
17
  * Chained: explore shape-focus then autopilot only to apply B with user OK.
18
18
  */
19
19
  export const POST_GREEN_PRIMARY_ACTION =
20
- 'Clarify for AI (Shape): /ark-explore shape-focus → dual-plan B, then /ark-autopilot only to apply B with your OK never empty plan A = done; patternBets never mechanical-safe';
20
+ 'Shape residual (design-weak): edges are clean, design is not finished. Map with /ark-explore shape-focus → dual-plan B; apply B only via /ark-autopilot with your OK. Empty plan A is not done; pattern bets are never mechanical-safe.';
21
21
 
22
22
  /** Short label for tables / metrics. */
23
23
  export const POST_GREEN_PRIMARY_SHORT =
24
- '/ark-explore shape-focus → /ark-autopilot (apply B with OK) # clarify for AI';
24
+ '/ark-explore shape-focus → /ark-autopilot (apply B with OK) # Shape residual';
25
25
 
26
26
  /**
27
27
  * @param {{ designWeak?: boolean } | null | undefined} designFitness
@@ -8,18 +8,28 @@ import { arkCommand } from '../ark-shared.mjs';
8
8
  import { codexPromptsDir, codexSkillsDir } from './codex-home.mjs';
9
9
  import { __packageRoot, isCompactRouterAgentsContent, readJson } from './gate-files.mjs';
10
10
 
11
+ /** Alias map so --tools agy installs the antigravity profile. */
12
+ const TOOL_ALIASES = Object.freeze({
13
+ agy: 'antigravity',
14
+ });
15
+
16
+ function normalizeToolId(value) {
17
+ const id = String(value).trim().toLowerCase();
18
+ return TOOL_ALIASES[id] ?? id;
19
+ }
20
+
11
21
  export function normalizeToolsList(tools) {
12
22
  if (tools == null) return [];
13
23
  if (Array.isArray(tools)) {
14
24
  return tools
15
25
  .flatMap((t) => String(t).split(','))
16
- .map((t) => t.trim().toLowerCase())
26
+ .map(normalizeToolId)
17
27
  .filter(Boolean);
18
28
  }
19
29
  if (typeof tools === 'string') {
20
30
  return tools
21
31
  .split(',')
22
- .map((t) => t.trim().toLowerCase())
32
+ .map(normalizeToolId)
23
33
  .filter(Boolean);
24
34
  }
25
35
  return [];
@@ -77,6 +87,25 @@ export function detectActiveAgentHost(env = process.env) {
77
87
  ) {
78
88
  return 'codex';
79
89
  }
90
+ // Google Antigravity / agy CLI
91
+ if (
92
+ envTruthy(env.ANTIGRAVITY) ||
93
+ envTruthy(env.AGY) ||
94
+ env.ANTIGRAVITY_WORKSPACE ||
95
+ env.AGY_WORKSPACE ||
96
+ /antigravity/i.test(String(env.TERM_PROGRAM ?? ''))
97
+ ) {
98
+ return 'antigravity';
99
+ }
100
+ // OpenCode
101
+ if (
102
+ envTruthy(env.OPENCODE) ||
103
+ env.OPENCODE_SESSION_ID ||
104
+ env.OPENCODE_CONFIG ||
105
+ envTruthy(env.OPENCODE_CLI)
106
+ ) {
107
+ return 'opencode';
108
+ }
80
109
  return null;
81
110
  }
82
111
 
@@ -101,6 +130,15 @@ export function resolveTools(args) {
101
130
  if (fs.existsSync(path.join(root, '.cursor'))) detected.add('cursor');
102
131
  if (fs.existsSync(path.join(root, '.codex'))) detected.add('codex');
103
132
  if (fs.existsSync(path.join(root, '.grok'))) detected.add('grok');
133
+ // Antigravity project hooks (distinct from Codex-only `.agents/skills`).
134
+ if (fs.existsSync(path.join(root, '.agents', 'hooks.json'))) detected.add('antigravity');
135
+ if (
136
+ fs.existsSync(path.join(root, 'opencode.json')) ||
137
+ fs.existsSync(path.join(root, 'opencode.jsonc')) ||
138
+ fs.existsSync(path.join(root, '.opencode'))
139
+ ) {
140
+ detected.add('opencode');
141
+ }
104
142
  if (fs.existsSync(path.join(root, '.windsurf'))) detected.add('windsurf');
105
143
  // .clinerules can also be a single FILE (older Cline convention); only a directory
106
144
  // can receive .clinerules/ark.md, so a file must not trigger detection.
@@ -136,6 +174,8 @@ export const KNOWN_TOOLS = [
136
174
  'cursor',
137
175
  'codex',
138
176
  'grok',
177
+ 'antigravity',
178
+ 'opencode',
139
179
  'windsurf',
140
180
  'cline',
141
181
  'copilot',
@@ -161,6 +201,10 @@ export const SKILL_TOOL_TARGETS = {
161
201
  codex: (name) => `.agents/skills/${name}/SKILL.md`,
162
202
  // Grok Build: project skills at .grok/skills/<name>/SKILL.md (slash-invocable).
163
203
  grok: (name) => `.grok/skills/${name}/SKILL.md`,
204
+ // Antigravity loads Agent Skills from `.agents/skills` (shared path with Codex).
205
+ antigravity: (name) => `.agents/skills/${name}/SKILL.md`,
206
+ // OpenCode project skills under `.opencode/skills`.
207
+ opencode: (name) => `.opencode/skills/${name}/SKILL.md`,
164
208
  windsurf: (name) => `.windsurf/workflows/${name}.md`,
165
209
  cline: (name) => `.clinerules/workflows/${name}.md`,
166
210
  copilot: (name) => `.github/prompts/${name}.prompt.md`,
@@ -5,7 +5,14 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { arkCommand, buildArchitectureRecommendation } from '../ark-shared.mjs';
7
7
  import { compactAgentInstructions, instructionRule, mcpJson } from './ci-and-commands.mjs';
8
- import { claudeSettings, codexHooks, grokHooks, grokProjectConfig } from './hook-templates.mjs';
8
+ import {
9
+ antigravityHooks,
10
+ claudeSettings,
11
+ codexHooks,
12
+ grokHooks,
13
+ grokProjectConfig,
14
+ opencodeProjectConfig,
15
+ } from './hook-templates.mjs';
9
16
 
10
17
  const COMPACT_HOST_TEMPLATES = {
11
18
  claude: (root) => [
@@ -16,8 +23,13 @@ const COMPACT_HOST_TEMPLATES = {
16
23
  ['.grok/config.toml', grokProjectConfig(root)],
17
24
  ['.grok/hooks/ark-write-gate.json', grokHooks(root)],
18
25
  ],
26
+ antigravity: (root) => [
27
+ ['.agents/hooks.json', antigravityHooks(root)],
28
+ ['.mcp.json', mcpJson(root)],
29
+ ],
19
30
  cursor: (root) => [['.cursor/mcp.json', mcpJson(root)]],
20
31
  codex: (root) => [['.codex/hooks.json', codexHooks(root)]],
32
+ opencode: (root) => [['opencode.json', opencodeProjectConfig(root)]],
21
33
  windsurf: (root) => [['.windsurf/rules/ark.md', instructionRule(root)]],
22
34
  cline: (root) => [['.clinerules/ark.md', instructionRule(root)]],
23
35
  copilot: (root) => [['.github/copilot-instructions.md', instructionRule(root)]],
@@ -194,9 +194,11 @@ function commandArkMcpInvocation(command) {
194
194
  }
195
195
 
196
196
  function requiredWriteOperations(relativePath) {
197
- return relativePath.startsWith('.grok/')
198
- ? ['write', 'search_replace']
199
- : ['Write', 'Edit', 'MultiEdit'];
197
+ if (relativePath.startsWith('.grok/')) return ['write', 'search_replace'];
198
+ if (relativePath === '.agents/hooks.json' || relativePath.startsWith('.agents/')) {
199
+ return ['write_to_file', 'replace_file_content', 'multi_replace_file_content'];
200
+ }
201
+ return ['Write', 'Edit', 'MultiEdit'];
200
202
  }
201
203
 
202
204
  function matcherOperations(relativePath, matcher) {
@@ -248,25 +250,39 @@ function tomlArkMcpIsValid(text) {
248
250
  return primary.length === 1 && tomlMcpBlockIsValid(primary[0].block);
249
251
  }
250
252
 
253
+ function collectPreToolUseGroups(parsed) {
254
+ // Claude/Grok/Codex: { hooks: { PreToolUse: [...] } }
255
+ if (Array.isArray(parsed?.hooks?.PreToolUse)) return parsed.hooks.PreToolUse;
256
+ // Antigravity: { "named-hook": { PreToolUse: [...] }, ... }
257
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
258
+ const groups = [];
259
+ for (const value of Object.values(parsed)) {
260
+ if (value && typeof value === 'object' && Array.isArray(value.PreToolUse)) {
261
+ groups.push(...value.PreToolUse);
262
+ }
263
+ }
264
+ return groups;
265
+ }
266
+ return [];
267
+ }
268
+
251
269
  function hookEvidence(root, relativePath) {
252
270
  const text = readText(path.join(root, relativePath));
253
271
  let hooks = [];
254
272
  try {
255
- const groups = JSON.parse(text)?.hooks?.PreToolUse;
256
- if (Array.isArray(groups)) {
257
- hooks = groups.flatMap((group) =>
258
- Array.isArray(group?.hooks)
259
- ? group.hooks
260
- .filter((hook) => !hook?.type || hook.type === 'command')
261
- .map((hook) => ({
262
- hook,
263
- invocation: commandArkMcpInvocation(hook?.command),
264
- operations: matcherOperations(relativePath, group.matcher),
265
- }))
266
- .filter((entry) => entry.invocation)
267
- : []
268
- );
269
- }
273
+ const groups = collectPreToolUseGroups(JSON.parse(text));
274
+ hooks = groups.flatMap((group) =>
275
+ Array.isArray(group?.hooks)
276
+ ? group.hooks
277
+ .filter((hook) => !hook?.type || hook.type === 'command')
278
+ .map((hook) => ({
279
+ hook,
280
+ invocation: commandArkMcpInvocation(hook?.command),
281
+ operations: matcherOperations(relativePath, group.matcher),
282
+ }))
283
+ .filter((entry) => entry.invocation)
284
+ : []
285
+ );
270
286
  } catch {
271
287
  hooks = [];
272
288
  }
@@ -290,6 +306,29 @@ function hookEvidence(root, relativePath) {
290
306
  };
291
307
  }
292
308
 
309
+ /** OpenCode local MCP: `{ mcp: { ark: { type: "local", command: [...] } } }`. */
310
+ function opencodeMcpIsValid(text) {
311
+ try {
312
+ const server = JSON.parse(text)?.mcp?.ark;
313
+ if (!server || typeof server !== 'object') return false;
314
+ if (server.type != null && server.type !== 'local') return false;
315
+ if (!Array.isArray(server.command) || server.command.length === 0) return false;
316
+ if (!server.command.every((value) => typeof value === 'string')) return false;
317
+ const [command, ...args] = server.command;
318
+ return mcpServerRunsArk({ command, args });
319
+ } catch {
320
+ return false;
321
+ }
322
+ }
323
+
324
+ function opencodeMcpEvidence(root) {
325
+ for (const relativePath of ['opencode.json', 'opencode.jsonc']) {
326
+ const text = readText(path.join(root, relativePath));
327
+ if (text && opencodeMcpIsValid(text)) return [relativePath];
328
+ }
329
+ return [];
330
+ }
331
+
293
332
  function mcpEvidence(root, relativePath) {
294
333
  const text = readText(path.join(root, relativePath));
295
334
  const valid = relativePath.endsWith('.toml')
@@ -338,6 +377,7 @@ export function detectWritePathInventory(root) {
338
377
  const merge = ci.failClosed ? ci.arkWorkflowFiles : [];
339
378
  const claudeHook = hookEvidence(root, '.claude/settings.json');
340
379
  const grokHook = hookEvidence(root, '.grok/hooks/ark-write-gate.json');
380
+ const antigravityHook = hookEvidence(root, '.agents/hooks.json');
341
381
  const hosts = {
342
382
  claude: hostRecord(
343
383
  claudeHook.hard,
@@ -351,12 +391,21 @@ export function detectWritePathInventory(root) {
351
391
  grokHook.repair,
352
392
  merge
353
393
  ),
394
+ antigravity: hostRecord(
395
+ antigravityHook.hard,
396
+ // Shared project MCP (.mcp.json) is the common advisory surface; hooks own hard write.
397
+ mcpEvidence(root, '.mcp.json'),
398
+ antigravityHook.repair,
399
+ merge
400
+ ),
354
401
  cursor: hostRecord([], mcpEvidence(root, '.cursor/mcp.json'), [], merge),
355
402
  // Codex 0.123+ emits PreToolUse for the native apply_patch handler, but some
356
403
  // Code Mode hosts execute deferred nested writes without dispatching that
357
404
  // project hook. Keep the installed hook as best-effort protection; do not
358
405
  // report a hard boundary that cannot be verified for every write surface.
359
406
  codex: hostRecord([], codexMcpEvidence(root), [], merge),
407
+ // OpenCode: MCP only (plugin hooks are incomplete / subagent-bypassable).
408
+ opencode: hostRecord([], opencodeMcpEvidence(root), [], merge),
360
409
  };
361
410
 
362
411
  const evidence = emptyEvidence();
@@ -11,7 +11,7 @@ import { buildWritePathCapabilityModel } from './write-path-capabilities.mjs';
11
11
 
12
12
  function installToolsForHost(activeHost) {
13
13
  return activeHost === 'unknown'
14
- ? 'claude,grok,cursor,codex'
14
+ ? 'claude,grok,antigravity,cursor,codex,opencode'
15
15
  : activeHost;
16
16
  }
17
17
 
@@ -109,22 +109,26 @@ export function detectWritePathCapabilities(root, explicitHost, attempt) {
109
109
  ),
110
110
  };
111
111
  } else if (mode === 'mcp-only') {
112
- const codexHonesty =
112
+ const honesty =
113
113
  activeHost === 'codex'
114
114
  ? 'Codex local write is advisory (MCP + best-effort hooks.json — not a hard boundary; ' +
115
115
  'not equivalent to Claude/Grok PreToolUse hard-write + repair). ' +
116
116
  'The hard merge backstop is CI --strict-merge plus a required status check.'
117
- : `Active host ${activeHost} has advisory prepare-write/autoPatch tools, ` +
118
- 'but no hard write boundary; CI can report failure, while merge blocking requires provider policy.';
117
+ : activeHost === 'opencode'
118
+ ? 'OpenCode local write is advisory (MCP + optional experimental plugin not a hard boundary; ' +
119
+ 'not equivalent to Claude/Grok/Antigravity PreToolUse hard-write). ' +
120
+ 'The hard merge backstop is CI --strict-merge plus a required status check.'
121
+ : `Active host ${activeHost} has advisory prepare-write/autoPatch tools, ` +
122
+ 'but no hard write boundary; CI can report failure, while merge blocking requires provider policy.';
119
123
  gap = {
120
124
  id: 'write-path-mcp-only',
121
125
  severity: 'info',
122
126
  host: activeHost,
123
- message: codexHonesty,
127
+ message: honesty,
124
128
  fix:
125
- activeHost === 'codex'
129
+ activeHost === 'codex' || activeHost === 'opencode'
126
130
  ? 'Keep CI on --strict-merge and require the ark-check status on the default branch; ' +
127
- `refresh Codex MCP/skills with ${arkCommand(root, 'ark-check', '--install-agent-gates --tools codex')}`
131
+ `refresh ${activeHost} MCP/skills with ${arkCommand(root, 'ark-check', `--install-agent-gates --tools ${activeHost}`)}`
128
132
  : arkCommand(root, 'ark-check', `--install-agent-gates --tools ${tools}`),
129
133
  };
130
134
  }