arkgate 3.8.2 → 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.
@@ -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)]],
@@ -70,12 +82,14 @@ function setupBudget(changes) {
70
82
  (total, item) => total + (item.afterBase64 ? Buffer.from(item.afterBase64, 'base64').length : 0),
71
83
  0
72
84
  );
85
+ // Compact start includes shared MCP + one host registration + CI + AGENTS + config.
86
+ // Budget raised from 5→8 so .mcp.json always fits (field: grok compact hit the old ceiling).
73
87
  return {
74
88
  files: generatedChanges.length,
75
89
  bytes,
76
- maxFiles: 5,
77
- maxBytes: 25 * 1024,
78
- ok: generatedChanges.length <= 5 && bytes < 25 * 1024,
90
+ maxFiles: 8,
91
+ maxBytes: 32 * 1024,
92
+ ok: generatedChanges.length <= 8 && bytes < 32 * 1024,
79
93
  };
80
94
  }
81
95
 
@@ -84,7 +98,8 @@ function commands(root, args, helpers) {
84
98
  return [`ark start --root ${root} --tools ${args.removeHost} --apply`];
85
99
  }
86
100
  const result = [];
87
- if (args.installExplicit && args.install && fs.existsSync(path.join(root, 'package.json'))) {
101
+ // Default install=true: surface the package pin/install command unless --no-install.
102
+ if (args.install !== false && fs.existsSync(path.join(root, 'package.json'))) {
88
103
  const [command, commandArgs] = helpers.packageInstallArgv(root, `^${helpers.cliVersion()}`);
89
104
  result.push(`${command} ${commandArgs.join(' ')}`);
90
105
  }
@@ -216,8 +231,9 @@ export async function planStart(args, helpers) {
216
231
  if (args.yes) childArgs.push('--yes');
217
232
  if (args.force) childArgs.push('--force');
218
233
  if (!args.strict) childArgs.push('--no-strict');
219
- if (args.installExplicit && args.install) childArgs.push('--install');
220
- if (args.installExplicit && !args.install) childArgs.push('--no-install');
234
+ // Propagate install intent into the shadow plan so package.json pin is in the diff by default.
235
+ if (args.install === false) childArgs.push('--no-install');
236
+ else childArgs.push('--install');
221
237
  if (args.tools) childArgs.push('--tools', args.tools);
222
238
  if (args.requireWriteHook) childArgs.push('--require-write-hook', args.requireWriteHook);
223
239
  const planned = spawnSync(process.execPath, [helpers.cliPath, ...childArgs], {
@@ -55,23 +55,50 @@ function verify(root, json, arkCheck, runArkCheck) {
55
55
  export function runUpgradeCommand(args, dependencies) {
56
56
  const root = args.root;
57
57
  if (args.apply && args.install) {
58
- const [command, commandArgs] = dependencies.packageInstallArgv(root);
59
- if (!args.json) console.log(`Updating ArkGate: ${command} ${commandArgs.join(' ')}`);
60
- const install = spawnSync(command, commandArgs, {
61
- cwd: root,
62
- stdio: args.json ? ['ignore', 'pipe', 'pipe'] : 'inherit',
63
- encoding: 'utf8',
64
- });
65
- const exitCode = install.status ?? 1;
66
- if (exitCode !== 0) {
67
- if (args.json && install.stderr) console.error(install.stderr.trim());
58
+ const skip =
59
+ typeof dependencies.shouldSkipArkgateInstall === 'function'
60
+ ? dependencies.shouldSkipArkgateInstall(root, dependencies.cliVersion)
61
+ : { skip: false };
62
+ if (skip.skip) {
63
+ if (!args.json) {
64
+ console.log(
65
+ `Package already at arkgate@${skip.installedVersion}; skipping install and recomputing managed preview.`
66
+ );
67
+ }
68
+ } else {
69
+ const [command, commandArgs] = dependencies.packageInstallArgv(root);
70
+ if (!args.json) console.log(`Updating ArkGate: ${command} ${commandArgs.join(' ')}`);
71
+ const install = spawnSync(command, commandArgs, {
72
+ cwd: root,
73
+ stdio: args.json ? ['ignore', 'pipe', 'pipe'] : 'inherit',
74
+ encoding: 'utf8',
75
+ });
76
+ const exitCode = install.status ?? 1;
77
+ if (exitCode !== 0) {
78
+ if (args.json && install.stderr) console.error(install.stderr.trim());
79
+ const recovery = `${command} ${commandArgs.join(' ')}`;
80
+ console.error(
81
+ `Package update failed (exit ${exitCode}). Fix the install and re-run:\n` +
82
+ ` ${recovery}\n` +
83
+ `Then: ark upgrade --no-install --root ${JSON.stringify(root)}` +
84
+ (args.tools ? ` --tools ${args.tools}` : '') +
85
+ (!args.strict ? ' --no-strict' : '') +
86
+ (args.json ? ' --json' : '')
87
+ );
88
+ return exitCode;
89
+ }
90
+ }
91
+ // Re-enter via installed CLI so the managed plan uses the newly installed package bytes.
92
+ let cli;
93
+ try {
94
+ cli = installedCli(root);
95
+ } catch {
68
96
  console.error(
69
- `Package update failed (exit ${exitCode}). Fix the install and re-run, or use ` +
70
- '`ark upgrade --no-install` against the installed version.'
97
+ 'arkgate is not installed in this project after the package step. Install it, then re-run with --no-install.'
71
98
  );
72
- return exitCode;
99
+ return 1;
73
100
  }
74
- return spawnSync(process.execPath, [installedCli(root), ...previewArgs(args)], {
101
+ return spawnSync(process.execPath, [cli, ...previewArgs(args)], {
75
102
  cwd: root,
76
103
  stdio: 'inherit',
77
104
  encoding: 'utf8',
@@ -111,7 +138,14 @@ export function runUpgradeCommand(args, dependencies) {
111
138
  return 0;
112
139
  }
113
140
 
114
- const applied = applyManagedUpgrade(root, plan, args.planDigest);
141
+ let applied;
142
+ try {
143
+ applied = applyManagedUpgrade(root, plan, args.planDigest);
144
+ } catch (error) {
145
+ const message = error instanceof Error ? error.message : String(error);
146
+ console.error(message);
147
+ return 2;
148
+ }
115
149
  if (applied.blocked) {
116
150
  if (args.json) console.log(JSON.stringify(applied, null, 2));
117
151
  else renderManagedUpgrade(applied, {
@@ -119,6 +153,14 @@ export function runUpgradeCommand(args, dependencies) {
119
153
  });
120
154
  return 1;
121
155
  }
156
+ if (applied.nothingToApply && !applied.applied) {
157
+ if (args.json) console.log(JSON.stringify(applied, null, 2));
158
+ else {
159
+ renderManagedUpgrade(applied);
160
+ console.log('No managed content writes pending (optional stamp refresh needs --plan-digest).');
161
+ }
162
+ return 0;
163
+ }
122
164
  const verification = args.strict
123
165
  ? { mode: 'strict-merge', ...verify(root, args.json, dependencies.arkCheck, dependencies.runArkCheck) }
124
166
  : { mode: 'skipped', exitCode: 0 };
@@ -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
  }