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.
@@ -36,6 +36,7 @@ import {
36
36
  mergePostGreenTopActions,
37
37
  isDoctorHealthyNothingToDo,
38
38
  } from './post-green-path.mjs';
39
+ import { doctorWritePathHonestyMessage } from './host-support-matrix.mjs';
39
40
  import {
40
41
  computePureLayerOptInNudge,
41
42
  loadGoldenPattern,
@@ -578,14 +579,20 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
578
579
  console.log('');
579
580
  console.log(color.bold('Operating mode'));
580
581
  // Modes are detected states, not user-picked settings. Plain-language "what you do next".
581
- const modeMark = mode === 'enforce' ? ok : mode === 'adapt' ? warn : warn;
582
+ // Never paint green (ok) under design residual edges clean design done (product-voice).
583
+ const modeMark =
584
+ mode === 'enforce' && !designFitness.designWeak
585
+ ? ok
586
+ : warn;
587
+ // Status lights are detected states, not user-picked settings (see docs/product-voice.md).
588
+ // modeTitle alone names the light — bodies must not re-prefix Suggest/Adapt/Enforce.
582
589
  const modeHelp = {
583
590
  suggest:
584
- 'Setup Ark proposes a starting architecture shape. You do not pick this mode; it means the tree is thin or new. Next: accept the shape (ark start / ark init) and add real layers as you grow.',
591
+ 'thin or new tree; the contract is not yet the control plane. You do not pick this light. Next: ark start (preview), then ark start --apply; re-check with --doctor.',
585
592
  adapt:
586
- 'Align — contract and folders still disagree, or coverage is weak / debt is open. You do not pick this mode. Next: classify ungoverned dirs (/ark-contract, /ark-adopt), run the plan (/ark-autopilot or /ark-loop). Gates do not fully protect you yet.',
593
+ 'contract and tree still disagree, or debt is open. Write path does not fully protect you yet. You do not pick this light. Next: do doctor top action #1 (often /ark-adopt, /ark-contract, or /ark-autopilot).',
587
594
  enforce:
588
- 'Guard — contract coverage is honest and checked edges are clean. You do not pick this mode; you arrived here. Next: keep the host-appropriate write path and CI check on; only NEW violations should fail.',
595
+ 'honest coverage and clean checked edges. You arrived here; you never turn Enforce on. Next: keep the host write path and CI check; only NEW violations should fail.',
589
596
  };
590
597
  const modeTitle =
591
598
  mode === 'enforce' && designFitness.designWeak
@@ -595,7 +602,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
595
602
  modeMark,
596
603
  `${modeTitle} — ${
597
604
  designFitness.designWeak
598
- ? 'Guard on edges is honest, but design smells remain (Shape residual). You do not pick this mode. Next: single path — /ark-explore shape-focus → dual-plan B, then /ark-autopilot only to apply B with your OK. Never empty plan A = done.'
605
+ ? 'checked edges are honest; design smells remain. Green is not elegant design. You do not pick this light. Next: one Shape door — /ark-explore shape-focus → dual-plan B; apply B only with /ark-autopilot and your OK. Empty plan A is not done.'
599
606
  : modeHelp[mode]
600
607
  }`
601
608
  );
@@ -692,34 +699,25 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
692
699
  if (showNewHere) {
693
700
  console.log('');
694
701
  console.log(color.bold('New here?'));
702
+ // Suggest residual: start → doctor only (not a competing recommend/architect curriculum).
703
+ line(ok, `Primary path: ${arkCommand(root, 'ark', 'start')} (preview) → ${arkCommand(root, 'ark', 'start --apply')} → re-run --doctor`);
695
704
  if (recommendation) {
696
- line(warn, `Suggested application shape: ${recommendation.archetype} — ${recommendation.label} (preset ${recommendation.preset})`);
697
- if (recommendation.galleryStarter) {
698
- line(ok, `Gallery starter: ${recommendation.galleryStarter}`);
699
- }
705
+ line(warn, `Sensor shape hint (not a second curriculum): ${recommendation.archetype} — ${recommendation.label} (preset ${recommendation.preset})`);
706
+ if (recommendation.galleryStarter) line(ok, `Gallery starter (optional): ${recommendation.galleryStarter}`);
700
707
  if (recommendation.policyPack) {
701
- line(ok, `Policy pack: ${arkCommand(root, 'ark-check', `--apply-policy-pack ${recommendation.policyPack}`)}`);
708
+ line(ok, `Policy pack (optional expert): ${arkCommand(root, 'ark-check', `--apply-policy-pack ${recommendation.policyPack}`)}`);
702
709
  }
703
710
  if (recommendation.signals?.nestFramework) {
704
- line(
705
- ok,
706
- 'Nest modular monolith → prefer hexagonal (or ddd-bounded-contexts if you have src/contexts/*)'
707
- );
711
+ line(ok, 'Nest modular monolith → prefer hexagonal (or ddd-bounded-contexts if you have src/contexts/*)');
708
712
  }
709
713
  if (recommendation.signals?.monorepoTooling?.length) {
710
- line(
711
- ok,
712
- `Monorepo tooling (${recommendation.signals.monorepoTooling.join(', ')}) → preset monorepo (apps/packages/libs)`
713
- );
714
+ line(ok, `Monorepo tooling (${recommendation.signals.monorepoTooling.join(', ')}) → preset monorepo (apps/packages/libs)`);
714
715
  }
715
716
  } else {
716
- line(warn, 'Low governed coverage or fresh config — pick an application shape before adding code.');
717
- }
718
- line(ok, `See the plan: ${arkCommand(root, 'ark-check', '--recommend')}`);
719
- if (recommendation?.archetype) {
720
- line(ok, `Quick setup: ${arkCommand(root, 'ark', `init --archetype ${recommendation.archetype} --yes`)}`);
717
+ line(warn, 'Low governed coverage or fresh config — finish start, then re-run doctor before adding layers of code.');
721
718
  }
722
- actions.unshift('run ark-check --recommend or /ark-architect to choose your application shape');
719
+ line(ok, `Optional sensor detail: ${arkCommand(root, 'ark-check', '--recommend')}`);
720
+ actions.unshift('finish ark start (preview + --apply), then re-run --doctor');
723
721
  }
724
722
 
725
723
  console.log('');
@@ -731,8 +729,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
731
729
  warn,
732
730
  'No active violations — coverage is still thin, so green is not yet honest enforcement'
733
731
  );
732
+ } else if (designFitness.designWeak) {
733
+ line(warn, 'None on checked edges — edges match the contract; design residual remains (ENFORCE · design-weak). Not healthy finished.');
734
734
  } else {
735
- line(ok, 'None — the code matches the contract');
735
+ line(ok, 'None — the code matches the contract on checked edges');
736
736
  }
737
737
  } else {
738
738
  const typeNote = summary.typeOnlyCount > 0 ? ` (${summary.valueCount} value · ${summary.typeOnlyCount} type-only)` : '';
@@ -770,6 +770,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
770
770
  line(' ', `Active host: ${writePath.activeHost}`);
771
771
  line(' ', `Supported profile: ${writePath.supportSummary}`);
772
772
  line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
773
+ const honestyLine = doctorWritePathHonestyMessage(writePath.activeHost, capabilities['hard-write']);
774
+ if (honestyLine) line(warn, honestyLine);
773
775
  if (writePath.sessionNote) {
774
776
  line(warn, writePath.sessionNote);
775
777
  }
@@ -932,18 +934,25 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
932
934
  const uniqueActions = mergePostGreenTopActions(actions, postGreenPath);
933
935
  if (isDoctorHealthyNothingToDo(designFitness, uniqueActions)) {
934
936
  console.log(color.green('✔ Healthy — nothing to do.'));
937
+ console.log(color.dim(' Contract edges and design residual are clear. Keep write path + CI.'));
935
938
  } else {
936
939
  if (designFitness.designWeak && uniqueActions.length === 0 && postGreenPath) {
937
940
  uniqueActions.push(postGreenPath.action);
938
941
  }
939
- console.log(color.bold(`Top actions (${uniqueActions.length}):`));
940
- uniqueActions.forEach((action, index) => console.log(` ${index + 1}. ${action}`));
942
+ console.log(color.bold(`Primary next action`));
943
+ console.log(` 1. ${uniqueActions[0]}`);
944
+ if (uniqueActions.length > 1) {
945
+ console.log(color.bold(`Also (${uniqueActions.length - 1}):`));
946
+ uniqueActions.slice(1).forEach((action, index) => console.log(` ${index + 2}. ${action}`));
947
+ }
941
948
  if (postGreenPath) {
942
949
  console.log(
943
950
  color.dim(
944
- ' (post-green path is primary when ENFORCE · design-weak — do not skill-shop explore vs coverage vs think)'
951
+ ' Shape residual is the primary door under ENFORCE · design-weak — do not skill-shop explore vs coverage vs think.'
945
952
  )
946
953
  );
954
+ } else {
955
+ console.log(color.dim(' Doctor is the control plane: do #1 first, then re-run --doctor.'));
947
956
  }
948
957
  }
949
958
  }
@@ -1,15 +1,19 @@
1
1
  // Generated from hook-templates.source.mjs — run npm run generate:packaged-tooling.
2
- import{execCommandParts as i,execRunner as a}from"../ark-shared.mjs";const r="arkgate-mcp";function g(e){const o=a(e);return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",command:`${o} ${r} --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit",hooks:[{type:"command",command:`${o} ${r} --hook --hook-repair --fail-on-new-smells --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`}]}]}},null,2)}
3
- `}function l(e){const o=a(e),n="${CODEX_PROJECT_DIR:-${PWD:-.}}";return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${o} ${r} --session-context --root "${n}" --config ark.config.json`}]}],PreToolUse:[{matcher:"ApplyPatch|apply_patch|Write|Edit|MultiEdit",hooks:[{type:"command",timeout:30,command:`${o} ${r} --hook --hook-repair --fail-on-new-smells --root "${n}" --config ark.config.json`}]}]}},null,2)}
4
- `}function p(e){const{command:o,args:n}=i(e,r,["--root",".","--config","ark.config.json"]),t=c=>c.replace(/\\/g,"\\\\").replace(/"/g,'\\"'),s=n.map(c=>`"${t(c)}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Codex project scope).
2
+ import{execCommandParts as i,execRunner as s}from"../ark-shared.mjs";const c="arkgate-mcp";function l(e){const r=s(e);return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",command:`${r} ${c} --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit",hooks:[{type:"command",command:`${r} ${c} --hook --hook-repair --fail-on-new-smells --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`}]}]}},null,2)}
3
+ `}function p(e){const r=s(e),o="${CODEX_PROJECT_DIR:-${PWD:-.}}";return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${r} ${c} --session-context --root "${o}" --config ark.config.json`}]}],PreToolUse:[{matcher:"ApplyPatch|apply_patch|Write|Edit|MultiEdit",hooks:[{type:"command",timeout:30,command:`${r} ${c} --hook --hook-repair --fail-on-new-smells --root "${o}" --config ark.config.json`}]}]}},null,2)}
4
+ `}function g(e){const{command:r,args:o}=i(e,c,["--root",".","--config","ark.config.json"]),t=a=>a.replace(/\\/g,"\\\\").replace(/"/g,'\\"'),n=o.map(a=>`"${t(a)}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Codex project scope).
5
5
  # Restart Codex after changes; MCP servers are loaded when the project session starts.
6
6
  [mcp_servers.ark]
7
- command = "${t(o)}"
8
- args = [${s}]
9
- `}function k(e){const{command:o,args:n}=i(e,r,["--root",".","--config","ark.config.json"]),t=n.map(s=>`"${s.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Grok Build project scope).
7
+ command = "${t(r)}"
8
+ args = [${n}]
9
+ `}function f(e){const{command:r,args:o}=i(e,c,["--root",".","--config","ark.config.json"]),t=o.map(n=>`"${n.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Grok Build project scope).
10
10
  # Restart Grok (or /mcps \u2192 refresh) after changes. Also loads repo-root .mcp.json.
11
11
  [mcp_servers.ark]
12
- command = "${o.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"
12
+ command = "${r.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"
13
13
  args = [${t}]
14
- `}function d(e){const o=a(e),n="${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}";return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${o} ${r} --session-context --root "${n}" --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit|write|search_replace",hooks:[{type:"command",timeout:30,command:`${o} ${r} --hook --hook-repair --fail-on-new-smells --root "${n}" --config ark.config.json`}]}]}},null,2)}
15
- `}export{r as PREFERRED_MCP_BIN,g as claudeSettings,l as codexHooks,p as codexProjectConfig,d as grokHooks,k as grokProjectConfig};
14
+ `}function u(e){const r=s(e),o="${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}";return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${r} ${c} --session-context --root "${o}" --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit|write|search_replace",hooks:[{type:"command",timeout:30,command:`${r} ${c} --hook --hook-repair --fail-on-new-smells --root "${o}" --config ark.config.json`}]}]}},null,2)}
15
+ `}function k(e){const r=s(e);return`${JSON.stringify({"ark-write-gate":{PreToolUse:[{matcher:"write_to_file|replace_file_content|multi_replace_file_content",hooks:[{type:"command",timeout:30,command:`${r} ${c} --hook --hook-repair --fail-on-new-smells --root "\${PWD:-.}" --config ark.config.json`}]}]}},null,2)}
16
+ `}function d(e){const{command:r,args:o}=i(e,c,["--root",".","--config","ark.config.json"]);return`${JSON.stringify({$schema:"https://opencode.ai/config.json",mcp:{ark:{type:"local",command:[r,...o],enabled:!0}}},null,2)}
17
+ `}function $(e,r){let o,t;try{o=e&&e.trim()?JSON.parse(e):{},t=JSON.parse(r)}catch{return null}if(!o||typeof o!="object"||Array.isArray(o)||!t||typeof t!="object"||Array.isArray(t))return null;const n=t["ark-write-gate"];if(!n||typeof n!="object")return null;const a={...o,"ark-write-gate":n};return`${JSON.stringify(a,null,2)}
18
+ `}function h(e,r){let o,t;try{o=e&&e.trim()?JSON.parse(e):{},t=JSON.parse(r)}catch{return null}if(!o||typeof o!="object"||Array.isArray(o))return null;const n={...o};!n.$schema&&t.$schema&&(n.$schema=t.$schema);const a=o.mcp&&typeof o.mcp=="object"&&!Array.isArray(o.mcp)?{...o.mcp}:{};return a.ark=t.mcp.ark,n.mcp=a,`${JSON.stringify(n,null,2)}
19
+ `}export{c as PREFERRED_MCP_BIN,k as antigravityHooks,l as claudeSettings,p as codexHooks,g as codexProjectConfig,u as grokHooks,f as grokProjectConfig,$ as mergeAntigravityArkHook,h as mergeOpencodeArkMcp,d as opencodeProjectConfig};
@@ -38,6 +38,16 @@ export const HOST_SUPPORT_MATRIX = Object.freeze({
38
38
  true,
39
39
  true
40
40
  ),
41
+ // Google Antigravity: official PreToolUse deny is a hard block. Claim hard only when
42
+ // installed + trusted and the listed write tools are covered by the adapter.
43
+ antigravity: hostProfile(
44
+ 'Google Antigravity',
45
+ '.agents/hooks.json',
46
+ 'PreToolUse `write_to_file` / `replace_file_content` / `multi_replace_file_content`',
47
+ ['write_to_file', 'replace_file_content', 'multi_replace_file_content'],
48
+ true,
49
+ true
50
+ ),
41
51
  cursor: hostProfile('Cursor', null, null, [], false, false),
42
52
  codex: hostProfile(
43
53
  'OpenAI Codex',
@@ -47,6 +57,16 @@ export const HOST_SUPPORT_MATRIX = Object.freeze({
47
57
  false,
48
58
  false
49
59
  ),
60
+ // OpenCode: first-class MCP + permissions; plugin tool.execute.before is incomplete
61
+ // (subagent holes). Never claim hard write.
62
+ opencode: hostProfile(
63
+ 'OpenCode',
64
+ null,
65
+ 'Advisory MCP + optional experimental plugin (`tool.execute.before`); not a hard boundary',
66
+ [],
67
+ false,
68
+ false
69
+ ),
50
70
  });
51
71
 
52
72
  export const HOST_SUPPORT_HOSTS = Object.freeze(Object.keys(HOST_SUPPORT_MATRIX));
@@ -70,18 +90,58 @@ export function renderHostSupportMatrixMarkdown() {
70
90
  const rows = HOST_SUPPORT_HOSTS.map((host) => {
71
91
  const profile = HOST_SUPPORT_MATRIX[host];
72
92
  const capabilities = profile.capabilities;
73
- const local = capabilities['hard-write']
74
- ? `Hard block for ${profile.hookSurface}`
75
- : 'No hard hook; MCP/rules are advisory';
93
+ // Fail-closed honesty: Cursor/Codex/OpenCode never claim hard write; CI is required-status.
94
+ // hookSurface already includes "PreToolUse …" — do not prefix PreToolUse again.
95
+ let local;
96
+ if (capabilities['hard-write']) {
97
+ local = `**Hard** block for listed ops (${profile.hookSurface}) when installed + trusted`;
98
+ } else if (host === 'codex') {
99
+ local =
100
+ '**Advisory / best-effort** at write (not equivalent to Claude/Grok hard block)';
101
+ } else if (host === 'opencode') {
102
+ local =
103
+ '**Advisory / best-effort** at write (MCP + optional plugin; not a hard boundary)';
104
+ } else {
105
+ local = '**Advisory only** at write (no hard hook)';
106
+ }
76
107
  const repair = capabilities['repair-payload']
77
108
  ? 'Emitted on hook deny; host must re-inject'
78
109
  : 'No hard-boundary payload';
79
- return `| ${profile.label} | ${local} | Advisory; the agent must call it | Available \`arkgate-check --strict-merge\` check | ${repair} |`;
110
+ const merge = capabilities['hard-write']
111
+ ? '**Required status** = hard merge boundary (`arkgate-check --strict-merge`)'
112
+ : '**Required status** = hard merge boundary (same CI)';
113
+ return `| ${profile.label} | ${local} | Advisory; the agent must call it | ${merge} | ${repair} |`;
80
114
  }).join('\n');
81
115
 
82
116
  return `| Host | Local write boundary | MCP validation | CI / merge path | Repair payload |
83
117
  |------|----------------------|----------------|-----------------|----------------|
84
118
  ${rows}
85
119
 
120
+ **Read the CI column:** for every host, the repository-wide hard guarantee is a **required**
121
+ merge check — not “CI file present.” Cursor/Codex/OpenCode never get a fake hard write claim.
122
+
86
123
  This table describes the supported profile **after its files are installed and the host loads/trusts them**. A hard local boundary covers only the listed hook operations; alternate tools, direct filesystem writes, and human edits still rely on CI. MCP validation is advisory because the agent must call it. The CI check blocks a merge only when the repository makes that status required. Repair payloads never write code silently: the host must re-inject the candidate and ArkGate revalidates it. Run \`arkgate-check --doctor\` for the evidence actually detected in the current repository.`;
87
124
  }
125
+
126
+ /**
127
+ * Doctor human one-liner for active-host write honesty (fail-closed).
128
+ * @returns {string|null}
129
+ */
130
+ export function doctorWritePathHonestyMessage(activeHost, hardWriteActive) {
131
+ const host = typeof activeHost === 'string' ? activeHost.trim().toLowerCase() : '';
132
+ if (host === 'cursor') {
133
+ return 'Cursor: write path is advisory (MCP/rules; no hard PreToolUse). Required CI status (arkgate-check --strict-merge) is the hard merge boundary.';
134
+ }
135
+ if (host === 'codex') {
136
+ return 'Codex: write path is advisory / best-effort at write (not Claude/Grok hard). Required CI status (arkgate-check --strict-merge) is the hard merge boundary.';
137
+ }
138
+ if (host === 'opencode') {
139
+ return 'OpenCode: write path is advisory / best-effort (MCP + optional plugin; not Claude/Grok/Antigravity hard). Required CI status (arkgate-check --strict-merge) is the hard merge boundary.';
140
+ }
141
+ if ((host === 'claude' || host === 'grok' || host === 'antigravity') && !hardWriteActive) {
142
+ const label =
143
+ host === 'claude' ? 'Claude' : host === 'grok' ? 'Grok' : 'Antigravity';
144
+ return `${label}: hard PreToolUse is supported for listed ops when installed + trusted; without runtime-observed hook evidence, hard is unverified. Required CI remains the merge hard boundary.`;
145
+ }
146
+ return null;
147
+ }
@@ -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,
@@ -149,7 +154,10 @@ export function buildManagedAssetCatalog({ root, tools, compact = false, skillsO
149
154
  'AGENTS.md',
150
155
  compact ? compactAgentInstructions(root, compactHost) : agentInstructions(root)
151
156
  );
152
- if (!compact || !compactHost || compactHost === 'claude') add('.mcp.json', mcpJson(root));
157
+ // Always write project MCP registration compact hosts other than Claude still need
158
+ // ark://manifest for agents (field: compact grok start left doctor reporting Missing .mcp.json
159
+ // when AGENTS lost compact markers or hosts were mixed).
160
+ add('.mcp.json', mcpJson(root));
153
161
  const deploy = detectDeployPathQuality(root);
154
162
  add(
155
163
  '.github/workflows/ark-check.yml',
@@ -172,6 +180,14 @@ export function buildManagedAssetCatalog({ root, tools, compact = false, skillsO
172
180
  add('.grok/config.toml', grokProjectConfig(root));
173
181
  add('.grok/hooks/ark-write-gate.json', grokHooks(root));
174
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
+ }
175
191
  if (selectedTools.has('windsurf')) add('.windsurf/rules/ark.md', instructionRule(root));
176
192
  if (selectedTools.has('cline')) add('.clinerules/ark.md', instructionRule(root));
177
193
  if (selectedTools.has('copilot')) {
@@ -235,6 +251,27 @@ export function runMigrateCommands(root) {
235
251
  } catch {
236
252
  continue;
237
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
+ }
238
275
  const ark = json?.mcpServers?.ark;
239
276
  if (!ark) continue;
240
277
  const binArgs = stripMcpServerArgs(ark.args);
@@ -313,6 +350,14 @@ export function runInstallAgentGates(args) {
313
350
  ? 'no active host detected'
314
351
  : 'default set — no agent config dirs found';
315
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
+ }
316
361
  // --skills-only refreshes just the canonical /ark-* skills, which are safe to
317
362
  // overwrite (they track the package). The gate/instruction files (AGENTS.md,
318
363
  // settings.json, CI workflow, rules) are the ones users customize, so a plain
@@ -364,6 +409,43 @@ export function runInstallAgentGates(args) {
364
409
  if (merged === existing) return { relativePath, status: 'skipped' };
365
410
  return writeTemplate(root, relativePath, merged, true);
366
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
+ }
367
449
  return writeTemplate(
368
450
  root,
369
451
  relativePath,
@@ -513,6 +595,19 @@ export function runInstallAgentGates(args) {
513
595
  console.log(' 3. Add the package.json alias if you want `run check:architecture`:');
514
596
  console.log(` ${checkArchitectureScriptSnippet(root)}`);
515
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
+ }
516
611
  if ((tools.has('codex') || args.codexHome)) {
517
612
  console.log('');
518
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'],
@@ -513,7 +515,20 @@ export function applyManagedUpgrade(root, plan, expectedPlanDigest) {
513
515
  const resolvedRoot = path.resolve(root);
514
516
  if (resolvedRoot !== plan.root) throw new Error('managed upgrade plan root mismatch');
515
517
  if (plan.summary.blocked > 0) return publicPlan(plan, { blocked: true });
518
+ const wouldWrite = plan.summary.wouldWrite ?? 0;
519
+ const metadataRefresh = plan.summary.metadataRefresh ?? 0;
520
+ // Content already matches: unbound --apply is a no-op (exit success), not a digest error.
521
+ // Optional stamp-only refresh still requires the preview's exact --plan-digest.
516
522
  if (!expectedPlanDigest || expectedPlanDigest !== plan.planDigest) {
523
+ if (wouldWrite === 0 && (plan.summary.blocked ?? 0) === 0 && !expectedPlanDigest) {
524
+ return publicPlan(plan, {
525
+ readOnly: true,
526
+ applied: false,
527
+ blocked: false,
528
+ nothingToApply: true,
529
+ optionalStampRefresh: metadataRefresh,
530
+ });
531
+ }
517
532
  throw new Error('managed upgrade plan digest mismatch; run a new preview and use its exact nextCommand');
518
533
  }
519
534
 
@@ -637,7 +652,21 @@ export function renderManagedUpgrade(plan, options = {}) {
637
652
  '.'
638
653
  );
639
654
  if (plan.applied) {
640
- console.log(`Applied changes: ${summary.changed}.`);
655
+ // Distinguish content writes from optional stamp/metadata bookkeeping.
656
+ if (wouldWrite === 0 && metadataRefresh > 0) {
657
+ console.log(
658
+ `Refreshed ${metadataRefresh} version stamp(s)` +
659
+ (summary.manifestChanged ? ' and managed manifest' : '') +
660
+ ' (no content body changes).'
661
+ );
662
+ } else {
663
+ console.log(
664
+ `Applied ${wouldWrite} content write(s)` +
665
+ (metadataRefresh > 0 ? `, ${metadataRefresh} stamp refresh(es)` : '') +
666
+ (summary.manifestChanged ? ', managed manifest' : '') +
667
+ '.'
668
+ );
669
+ }
641
670
  return;
642
671
  }
643
672
  // Content already matches package templates — do not urge --apply as the primary next step.
@@ -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