arkgate 3.7.0 → 3.8.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +76 -1145
  2. package/README.md +59 -19
  3. package/bin/ark-check-runtime.mjs +1598 -0
  4. package/bin/ark-check.mjs +32 -1565
  5. package/bin/ark-mcp-runtime.mjs +1976 -0
  6. package/bin/ark-mcp.mjs +84 -1495
  7. package/bin/ark-shared.mjs +34 -38
  8. package/bin/ark.mjs +33 -66
  9. package/bin/lib/adapter-contract.mjs +161 -9
  10. package/bin/lib/agent-gates.mjs +1 -0
  11. package/bin/lib/analysis-completeness.mjs +28 -0
  12. package/bin/lib/analysis-engine.mjs +8 -8
  13. package/bin/lib/analysis-policy.mjs +27 -0
  14. package/bin/lib/architecture-scan.mjs +70 -357
  15. package/bin/lib/auto-patch.mjs +76 -8
  16. package/bin/lib/ci-and-commands.mjs +1 -1
  17. package/bin/lib/codex-home.mjs +43 -16
  18. package/bin/lib/design-delta.mjs +4 -0
  19. package/bin/lib/doctor-advisories.mjs +4 -3
  20. package/bin/lib/doctor-plan.mjs +40 -41
  21. package/bin/lib/enforcement-state.mjs +2 -0
  22. package/bin/lib/github-enforcement.mjs +443 -0
  23. package/bin/lib/hook-templates.mjs +12 -148
  24. package/bin/lib/html-report-advisories.mjs +1 -1
  25. package/bin/lib/html-report-depth.mjs +9 -0
  26. package/bin/lib/html-report.mjs +5 -5
  27. package/bin/lib/install-migrate.mjs +83 -79
  28. package/bin/lib/managed-upgrade.mjs +622 -0
  29. package/bin/lib/mcp-adoption.mjs +3 -1
  30. package/bin/lib/parse-health.mjs +6 -5
  31. package/bin/lib/port-proof.mjs +2 -2
  32. package/bin/lib/prepare-change.mjs +68 -38
  33. package/bin/lib/prepare-write.mjs +7 -1
  34. package/bin/lib/resident-doctor-client.mjs +55 -0
  35. package/bin/lib/resident-hook.mjs +247 -0
  36. package/bin/lib/resolved-candidate-facts.mjs +1160 -0
  37. package/bin/lib/scan-files.mjs +19 -6
  38. package/bin/lib/snippet-analysis.mjs +119 -0
  39. package/bin/lib/source-policy.mjs +24 -0
  40. package/bin/lib/typescript-host.mjs +15 -18
  41. package/bin/lib/unavailable-analysis.mjs +76 -0
  42. package/bin/lib/upgrade-command.mjs +115 -0
  43. package/bin/lib/weakest-link.mjs +21 -179
  44. package/bin/lib/write-path-capabilities.mjs +167 -16
  45. package/bin/lib/write-path-detect.mjs +3 -2
  46. package/dist/eslint/index.cjs +3 -3
  47. package/dist/eslint/index.d.ts +3 -0
  48. package/dist/eslint/index.js +3 -3
  49. package/dist/index.cjs +7 -7
  50. package/dist/index.d.ts +1073 -141
  51. package/dist/index.js +7 -7
  52. package/docs/agent-guide.md +103 -59
  53. package/docs/ai-gates.md +97 -16
  54. package/docs/demos/01-write-gate-self-correction.md +2 -2
  55. package/docs/enthusiast/README.md +10 -10
  56. package/docs/enthusiast/how-to-gallery-starter.md +2 -2
  57. package/docs/enthusiast/reference-commands.md +18 -1
  58. package/docs/enthusiast/tutorial-first-project.md +2 -2
  59. package/docs/package-surface.md +98 -12
  60. package/docs/typescript-support.md +109 -37
  61. package/package.json +32 -4
  62. package/schemas/ark.analysis-result.schema.json +159 -2
  63. package/schemas/ark.design-delta.schema.json +1 -0
  64. package/schemas/ark.enforcement-state.schema.json +84 -0
  65. package/schemas/ark.resolved-candidate-facts.schema.json +1 -0
  66. package/server.json +2 -2
  67. package/templates/skills/ark-explore.md +5 -5
  68. package/templates/skills/ark-fix.md +1 -1
  69. package/templates/skills/ark-runtime.md +15 -8
  70. package/templates/skills/ark-upgrade.md +122 -182
  71. package/bin/lib/ai-velocity.mjs +0 -293
  72. package/bin/lib/graph-cycles.mjs +0 -6
  73. package/bin/lib/safety-diagnostics.mjs +0 -284
  74. package/bin/lib/ts-resolve.mjs +0 -228
  75. package/dist/configTypes-DAPvBqK6.d.cts +0 -61
  76. package/dist/eslint/index.d.cts +0 -146
  77. package/dist/index.d.cts +0 -986
@@ -2,10 +2,20 @@
2
2
  * Q3 — Weakest-link enforcement sensors (local, pure FS; optional gh for protection).
3
3
  * Missing CI, config drift, pre-commit human-edit path, and honest branch-protection report.
4
4
  */
5
- import { spawnSync } from 'node:child_process';
6
5
  import fs from 'node:fs';
7
6
  import path from 'node:path';
8
7
  import { arkCommand } from '../ark-shared.mjs';
8
+ import {
9
+ enforcingArkRunText,
10
+ reportGithubBranchProtection,
11
+ runsArkCheck,
12
+ } from './github-enforcement.mjs';
13
+
14
+ export {
15
+ isArkRequiredStatusCheck,
16
+ jobIdsThatRunArkCheck,
17
+ reportGithubBranchProtection,
18
+ } from './github-enforcement.mjs';
9
19
 
10
20
  const PRECOMMIT_MARKERS = [
11
21
  'ark-check',
@@ -60,13 +70,12 @@ export function classifyArkCheckFlags(text) {
60
70
  if (!text || typeof text !== 'string') {
61
71
  return { hasFailClosedFlag: false, hasStrictConfigOnly: false, hasStrictFlag: false };
62
72
  }
63
- const hasStrictMerge = /--strict-merge\b/.test(text);
64
- const hasRequireGates = /--require-gates\b/.test(text);
73
+ const hasStrictMerge = /(?:^|\s)--strict-merge(?=\s|$)/.test(text);
74
+ const hasRequireGates = /(?:^|\s)--require-gates(?=\s|$)/.test(text);
65
75
  // Bare --strict (alias of --strict-merge), not --strict-config / already-matched merge.
66
- const withoutLong = text.replace(/--strict-merge\b/g, ' ').replace(/--strict-config\b/g, ' ');
67
- const hasBareStrict = /--strict\b/.test(withoutLong);
76
+ const hasBareStrict = /(?:^|\s)--strict(?=\s|$)/.test(text);
68
77
  const hasFailClosedFlag = hasStrictMerge || hasRequireGates || hasBareStrict;
69
- const hasStrictConfig = /--strict-config\b/.test(text);
78
+ const hasStrictConfig = /(?:^|\s)--strict-config(?=\s|$)/.test(text);
70
79
  const hasStrictConfigOnly = hasStrictConfig && !hasFailClosedFlag;
71
80
  const hasStrictFlag = hasFailClosedFlag || hasStrictConfigOnly;
72
81
  return { hasFailClosedFlag, hasStrictConfigOnly, hasStrictFlag };
@@ -118,8 +127,6 @@ export function detectCiEnforcement(root) {
118
127
  } catch {
119
128
  checkArchScript = '';
120
129
  }
121
- const scriptFlags = classifyArkCheckFlags(checkArchScript);
122
-
123
130
  for (const f of files) {
124
131
  let text = '';
125
132
  try {
@@ -127,17 +134,13 @@ export function detectCiEnforcement(root) {
127
134
  } catch {
128
135
  continue;
129
136
  }
130
- const mentionsArk =
131
- /\barkgate-check\b/.test(text) ||
132
- /\bark-check\b/.test(text) ||
133
- /check:architecture/.test(text) ||
134
- /pedroknigge\/arkgate/.test(text);
137
+ const arkRunText = enforcingArkRunText(text, checkArchScript);
138
+ const mentionsArk = runsArkCheck(text, checkArchScript);
135
139
  if (mentionsArk) {
136
140
  out.hasArkCheckWorkflow = true;
137
141
  out.arkWorkflowFiles.push(`.github/workflows/${f}`);
138
- const flags = classifyArkCheckFlags(text);
139
- const viaScript = /check:architecture/.test(text) && scriptFlags.hasFailClosedFlag;
140
- if (flags.hasFailClosedFlag || viaScript) {
142
+ const flags = classifyArkCheckFlags(arkRunText);
143
+ if (flags.hasFailClosedFlag) {
141
144
  out.hasFailClosedFlag = true;
142
145
  out.failClosed = true;
143
146
  out.hasStrictFlag = true;
@@ -156,60 +159,6 @@ export function detectCiEnforcement(root) {
156
159
  return out;
157
160
  }
158
161
 
159
- /**
160
- * Job ids (GitHub Actions) whose job body runs ark-check / check:architecture.
161
- * Used so required status check "build" counts when the build job runs Ark.
162
- * @param {string} root
163
- * @returns {Set<string>}
164
- */
165
- export function jobIdsThatRunArkCheck(root) {
166
- const ids = new Set();
167
- const wfDir = path.join(root, '.github', 'workflows');
168
- if (!fs.existsSync(wfDir)) return ids;
169
- let files = [];
170
- try {
171
- files = fs.readdirSync(wfDir).filter((f) => /\.ya?ml$/i.test(f));
172
- } catch {
173
- return ids;
174
- }
175
- for (const f of files) {
176
- let text = '';
177
- try {
178
- text = fs.readFileSync(path.join(wfDir, f), 'utf8');
179
- } catch {
180
- continue;
181
- }
182
- if (!/\barkgate-check\b|\bark-check\b|check:architecture/.test(text)) continue;
183
- const jobsIdx = text.search(/^jobs:\s*$/m);
184
- if (jobsIdx < 0) continue;
185
- const jobsSection = text.slice(jobsIdx);
186
- const re = /^ {2}([A-Za-z0-9_-]+):\s*$/gm;
187
- const matches = [...jobsSection.matchAll(re)];
188
- for (let i = 0; i < matches.length; i++) {
189
- const jobId = matches[i][1];
190
- const start = matches[i].index ?? 0;
191
- const end = i + 1 < matches.length ? (matches[i + 1].index ?? jobsSection.length) : jobsSection.length;
192
- const body = jobsSection.slice(start, end);
193
- if (/\barkgate-check\b|\bark-check\b|check:architecture/.test(body)) {
194
- ids.add(jobId);
195
- }
196
- }
197
- }
198
- return ids;
199
- }
200
-
201
- /**
202
- * Whether required status checks include Ark (by name or by matching a job that runs Ark).
203
- * @param {string} root
204
- * @param {string[]} requiredNames
205
- */
206
- export function isArkRequiredStatusCheck(root, requiredNames) {
207
- if (!Array.isArray(requiredNames) || requiredNames.length === 0) return false;
208
- if (requiredNames.some((n) => /ark|architecture|arkgate/i.test(String(n)))) return true;
209
- const jobIds = jobIdsThatRunArkCheck(root);
210
- return requiredNames.some((n) => jobIds.has(String(n)));
211
- }
212
-
213
162
  /**
214
163
  * Config / gate surface drift (adopted projects only).
215
164
  * @param {string} root
@@ -258,106 +207,6 @@ export function detectConfigGateDrift(root, opts = {}) {
258
207
  return { hasConfig, hasAgents, hasCheckScript, issues };
259
208
  }
260
209
 
261
- /**
262
- * Optional GitHub branch-protection / required-check report.
263
- * Never fakes green: unavailable when gh missing, no remote, or API error.
264
- *
265
- * @param {{ cwd?: string, repo?: string, branch?: string, env?: NodeJS.ProcessEnv }} [opts]
266
- * @returns {{
267
- * available: boolean,
268
- * reason?: string,
269
- * requiredStatusChecks?: string[] | null,
270
- * strict?: boolean | null,
271
- * enforcesAdmins?: boolean | null,
272
- * arkCheckRequired?: boolean | null,
273
- * raw?: unknown,
274
- * }}
275
- */
276
- export function reportGithubBranchProtection(opts = {}) {
277
- const cwd = opts.cwd ?? process.cwd();
278
- const env = opts.env ?? process.env;
279
- const gh = spawnSync('gh', ['--version'], { encoding: 'utf8', env });
280
- if (gh.status !== 0) {
281
- return { available: false, reason: 'gh-cli-unavailable' };
282
- }
283
-
284
- let repo = opts.repo;
285
- if (!repo) {
286
- const r = spawnSync('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], {
287
- cwd,
288
- encoding: 'utf8',
289
- env,
290
- });
291
- if (r.status !== 0 || !r.stdout?.trim()) {
292
- return { available: false, reason: 'gh-repo-unavailable', raw: r.stderr };
293
- }
294
- repo = r.stdout.trim();
295
- }
296
-
297
- let branch = opts.branch;
298
- if (!branch) {
299
- const b = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
300
- cwd,
301
- encoding: 'utf8',
302
- env,
303
- });
304
- branch = b.status === 0 ? b.stdout.trim() : 'main';
305
- if (branch === 'HEAD') branch = 'main';
306
- }
307
-
308
- const view = spawnSync(
309
- 'gh',
310
- [
311
- 'api',
312
- `repos/${repo}/branches/${encodeURIComponent(branch)}/protection`,
313
- '--jq',
314
- '{strict: .required_status_checks.strict, contexts: .required_status_checks.contexts, checks: .required_status_checks.checks, enforcesAdmins: .enforce_admins.enabled}',
315
- ],
316
- { cwd, encoding: 'utf8', env }
317
- );
318
-
319
- if (view.status !== 0) {
320
- const err = `${view.stderr || ''}${view.stdout || ''}`;
321
- if (/Not Found|404|Branch not protected/i.test(err)) {
322
- return {
323
- available: true,
324
- reason: 'branch-not-protected',
325
- requiredStatusChecks: [],
326
- strict: false,
327
- enforcesAdmins: false,
328
- arkCheckRequired: false,
329
- raw: err.slice(0, 400),
330
- };
331
- }
332
- return { available: false, reason: 'gh-api-error', raw: err.slice(0, 400) };
333
- }
334
-
335
- let parsed = null;
336
- try {
337
- parsed = JSON.parse(view.stdout || '{}');
338
- } catch {
339
- return { available: false, reason: 'gh-api-parse-error', raw: view.stdout };
340
- }
341
-
342
- const contexts = Array.isArray(parsed.contexts) ? parsed.contexts.map(String) : [];
343
- const checkNames = Array.isArray(parsed.checks)
344
- ? parsed.checks.map((c) => String(c?.context || c?.name || '')).filter(Boolean)
345
- : [];
346
- const all = [...new Set([...contexts, ...checkNames])];
347
- // Name match OR required check id equals a workflow job that runs ark-check (e.g. "build").
348
- const arkCheckRequired = isArkRequiredStatusCheck(cwd, all);
349
-
350
- return {
351
- available: true,
352
- reason: 'ok',
353
- requiredStatusChecks: all,
354
- strict: Boolean(parsed.strict),
355
- enforcesAdmins: Boolean(parsed.enforcesAdmins),
356
- arkCheckRequired,
357
- raw: parsed,
358
- };
359
- }
360
-
361
210
  /**
362
211
  * Adoption gaps for weakest-link (Q3). Does not require network.
363
212
  * @param {string} root
@@ -434,14 +283,7 @@ export function collectWeakestLinkGaps(root, opts = {}) {
434
283
  let github = null;
435
284
  if (opts.includeGithub) {
436
285
  github = reportGithubBranchProtection({ cwd: root });
437
- if (github.available && github.reason === 'branch-not-protected') {
438
- gaps.push({
439
- id: 'enforcement-branch-unprotected',
440
- severity: 'warn',
441
- message: 'Default branch has no GitHub branch protection (architecture check cannot be required)',
442
- fix: 'Enable branch protection and require the architecture / ark-check status check',
443
- });
444
- } else if (
286
+ if (
445
287
  github.available &&
446
288
  github.arkCheckRequired === false &&
447
289
  Array.isArray(github.requiredStatusChecks)
@@ -450,7 +292,7 @@ export function collectWeakestLinkGaps(root, opts = {}) {
450
292
  id: 'enforcement-ark-check-not-required',
451
293
  severity: 'warn',
452
294
  message:
453
- 'Branch protection exists but no required status check looks like ark/architecture',
295
+ 'Branch protection exists but no required status check matches a local Ark-running job',
454
296
  fix: 'Add the architecture CI job name to required status checks',
455
297
  });
456
298
  }
@@ -10,6 +10,7 @@ import path from 'node:path';
10
10
  import {
11
11
  codexProjectMcpIsValid,
12
12
  codexConfigPath,
13
+ extractCodexArgsFromBlock,
13
14
  listCodexArkServerTables,
14
15
  } from './codex-home.mjs';
15
16
  import {
@@ -18,6 +19,7 @@ import {
18
19
  } from './host-support-matrix.mjs';
19
20
  import { detectActiveAgentHost } from './skill-install.mjs';
20
21
  import { detectCiEnforcement } from './weakest-link.mjs';
22
+ import { buildEnforcementState } from './enforcement-state.mjs';
21
23
 
22
24
  export const WRITE_CAPABILITY_NAMES = [
23
25
  'hard-write',
@@ -126,22 +128,161 @@ function relativeEvidencePath(root, file) {
126
128
  return file.split(path.sep).join('/');
127
129
  }
128
130
 
129
- function isArkMcpText(text) {
130
- return (
131
- /\b(ark|arkgate)-mcp\b/.test(text) ||
132
- /mcp_servers\.ark\b/.test(text) ||
133
- /"ark"\s*:\s*\{/.test(text) ||
134
- /mcpServers[\s\S]*\bark\b/.test(text)
131
+ function isArkMcpToken(value) {
132
+ return /^(?:arkgate-mcp|ark-mcp)(?:\.mjs)?$/.test(
133
+ path.basename(value.trim().replace(/\\/g, '/'))
135
134
  );
136
135
  }
137
136
 
137
+ function arkMcpInvocation(server) {
138
+ if (!server || typeof server !== 'object' || typeof server.command !== 'string') return false;
139
+ if (server.args !== undefined && !Array.isArray(server.args)) return false;
140
+ const args = server.args ?? [];
141
+ if (!args.every((value) => typeof value === 'string')) return false;
142
+ const argv = [server.command, ...args];
143
+ if (argv.filter(isArkMcpToken).length !== 1) return false;
144
+ if (isArkMcpToken(server.command)) return { argv, binIndex: 0 };
145
+ const runner = path.basename(server.command.trim().replace(/\\/g, '/'));
146
+ if (['npx', 'yarn'].includes(runner) && isArkMcpToken(args[0])) {
147
+ return { argv, binIndex: 1 };
148
+ }
149
+ if (runner === 'node') {
150
+ const script = args[0]?.replace(/\\/g, '/');
151
+ return isArkMcpToken(script) && /(?:^|\/)bin\/ark-mcp\.mjs$/.test(script)
152
+ ? { argv, binIndex: 1 }
153
+ : false;
154
+ }
155
+ if (runner === 'pnpm') {
156
+ const prefix = args[0] === 'exec'
157
+ ? 1
158
+ : args[0] === '--config.verify-deps-before-run=false' && args[1] === 'exec'
159
+ ? 2
160
+ : 0;
161
+ return prefix > 0 && isArkMcpToken(args[prefix])
162
+ ? { argv, binIndex: prefix + 1 }
163
+ : false;
164
+ }
165
+ return false;
166
+ }
167
+
168
+ function mcpServerRunsArk(server) {
169
+ return Boolean(arkMcpInvocation(server));
170
+ }
171
+
172
+ function commandArkMcpInvocation(command) {
173
+ if (typeof command !== 'string') return false;
174
+ const words = [];
175
+ for (const match of command.matchAll(/"([^"]*)"|'([^']*)'|(&&|\|\||[;|#])|([^\s;&|#]+)/g)) {
176
+ if (match[3]) break;
177
+ words.push(match[1] ?? match[2] ?? match[4]);
178
+ }
179
+ if (path.basename(words[0]?.replace(/\\/g, '/') ?? '') === 'env') words.shift();
180
+ const environment = {};
181
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] ?? '')) {
182
+ const assignment = words.shift();
183
+ const split = assignment.indexOf('=');
184
+ const value = assignment.slice(split + 1);
185
+ environment[assignment.slice(0, split)] = /^(['"]).*\1$/.test(value)
186
+ ? value.slice(1, -1)
187
+ : value;
188
+ }
189
+ const [executable, ...args] = words;
190
+ const invocation = arkMcpInvocation({ command: executable, args });
191
+ return invocation
192
+ ? { ...invocation, binArgs: invocation.argv.slice(invocation.binIndex + 1), environment }
193
+ : false;
194
+ }
195
+
196
+ function requiredWriteOperations(relativePath) {
197
+ return relativePath.startsWith('.grok/')
198
+ ? ['write', 'search_replace']
199
+ : ['Write', 'Edit', 'MultiEdit'];
200
+ }
201
+
202
+ function matcherOperations(relativePath, matcher) {
203
+ const required = requiredWriteOperations(relativePath);
204
+ if (typeof matcher !== 'string' || matcher.trim() === '') return required;
205
+ try {
206
+ const pattern = new RegExp(`^(?:${matcher})$`);
207
+ return required.filter((operation) => pattern.test(operation));
208
+ } catch {
209
+ return [];
210
+ }
211
+ }
212
+
213
+ function jsonArkMcpIsValid(text) {
214
+ try {
215
+ return mcpServerRunsArk(JSON.parse(text)?.mcpServers?.ark);
216
+ } catch {
217
+ return false;
218
+ }
219
+ }
220
+
221
+ function tomlMcpBlockIsValid(block) {
222
+ const matches = [
223
+ ...block.matchAll(
224
+ /^[ \t]*command[ \t]*=[ \t]*("(?:\\.|[^"\\])*"|'[^']*')[ \t]*(?:#.*)?$/gm
225
+ ),
226
+ ];
227
+ const args = extractCodexArgsFromBlock(block);
228
+ if (matches.length !== 1 || !args) return false;
229
+ const validLine = (line) =>
230
+ /^[ \t]*(?:#.*)?$/.test(line) ||
231
+ /^[ \t]*\[.*\][ \t]*(?:#.*)?$/.test(line) ||
232
+ /^[ \t]*command[ \t]*=[ \t]*("(?:\\.|[^"\\])*"|'[^']*')[ \t]*(?:#.*)?$/.test(line) ||
233
+ /^[ \t]*args[ \t]*=[ \t]*\[[^\]\r\n]*\][ \t]*(?:#.*)?$/.test(line);
234
+ if (block.split('\n').some((line) => !validLine(line))) return false;
235
+ let command;
236
+ try {
237
+ command = matches[0][1].startsWith('"')
238
+ ? JSON.parse(matches[0][1])
239
+ : matches[0][1].slice(1, -1);
240
+ } catch {
241
+ return false;
242
+ }
243
+ return mcpServerRunsArk({ command, args });
244
+ }
245
+
246
+ function tomlArkMcpIsValid(text) {
247
+ const primary = listCodexArkServerTables(text).filter((entry) => entry.table === 'ark');
248
+ return primary.length === 1 && tomlMcpBlockIsValid(primary[0].block);
249
+ }
250
+
138
251
  function hookEvidence(root, relativePath) {
139
252
  const text = readText(path.join(root, relativePath));
140
- const hard = Boolean(text && /--hook\b/.test(text));
141
- const repair = Boolean(
142
- hard &&
143
- (/--hook-repair\b/.test(text) ||
144
- /ARK_HOOK_REPAIR\s*=\s*['"]?(1|true|yes|on)/i.test(text))
253
+ let hooks = [];
254
+ 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
+ }
270
+ } catch {
271
+ hooks = [];
272
+ }
273
+ const hardHooks = hooks.filter((entry) => entry.invocation.binArgs.includes('--hook'));
274
+ const required = requiredWriteOperations(relativePath);
275
+ const hard = required.every((operation) =>
276
+ hardHooks.some((entry) => entry.operations.includes(operation))
277
+ );
278
+ const repair = hard && required.every((operation) =>
279
+ hardHooks.some(({ hook, invocation, operations }) =>
280
+ operations.includes(operation) &&
281
+ (invocation.binArgs.includes('--hook-repair') ||
282
+ /^(?:1|true|yes|on)$/i.test(
283
+ String(hook.env?.ARK_HOOK_REPAIR ?? invocation.environment.ARK_HOOK_REPAIR ?? '')
284
+ ))
285
+ )
145
286
  );
146
287
  return {
147
288
  hard: hard ? [relativePath] : [],
@@ -151,20 +292,25 @@ function hookEvidence(root, relativePath) {
151
292
 
152
293
  function mcpEvidence(root, relativePath) {
153
294
  const text = readText(path.join(root, relativePath));
154
- return isArkMcpText(text) ? [relativePath] : [];
295
+ const valid = relativePath.endsWith('.toml')
296
+ ? tomlArkMcpIsValid(text)
297
+ : jsonArkMcpIsValid(text);
298
+ return valid ? [relativePath] : [];
155
299
  }
156
300
 
157
301
  function codexMcpEvidence(root) {
158
302
  const projectFile = path.join(root, '.codex', 'config.toml');
159
303
  const projectText = readText(projectFile);
160
- const projectRegistered = codexProjectMcpIsValid(projectText, root);
304
+ const projectRegistered =
305
+ codexProjectMcpIsValid(projectText, root) && tomlArkMcpIsValid(projectText);
161
306
  if (projectRegistered) return ['.codex/config.toml'];
162
307
 
163
308
  const file = codexConfigPath();
164
309
  const text = readText(file);
165
310
  const resolvedRoot = path.resolve(root);
166
- const registered = listCodexArkServerTables(text).some((entry) => {
167
- if (!entry.root || !/\b(ark|arkgate)-mcp\b/.test(entry.block)) return false;
311
+ const tables = listCodexArkServerTables(text);
312
+ const registered = new Set(tables.map((entry) => entry.table)).size === tables.length && tables.some((entry) => {
313
+ if (!entry.root || !tomlMcpBlockIsValid(entry.block)) return false;
168
314
  return path.resolve(entry.root) === resolvedRoot;
169
315
  });
170
316
  return registered ? [relativeEvidencePath(root, file)] : [];
@@ -227,11 +373,13 @@ export function detectWritePathInventory(root) {
227
373
  capabilities: capabilityMap(evidence),
228
374
  evidence,
229
375
  hosts,
376
+ ci,
230
377
  };
231
378
  }
232
379
 
233
380
  export function buildWritePathCapabilityModel(root, explicitHost, attempt) {
234
381
  const inventory = detectWritePathInventory(root);
382
+ const ci = inventory.ci;
235
383
  const detectedHost = explicitHost ?? detectActiveAgentHost();
236
384
  const activeHost = KNOWN_HOSTS.includes(detectedHost) ? detectedHost : 'unknown';
237
385
  const activeRecord = inventory.hosts[activeHost];
@@ -244,7 +392,7 @@ export function buildWritePathCapabilityModel(root, explicitHost, attempt) {
244
392
  'merge-gate': [...inventory.evidence['merge-gate']],
245
393
  };
246
394
 
247
- return {
395
+ const model = {
248
396
  activeHost,
249
397
  support: getHostSupportProfile(activeHost),
250
398
  capabilities: capabilityMap(capabilityEvidence),
@@ -257,4 +405,7 @@ export function buildWritePathCapabilityModel(root, explicitHost, attempt) {
257
405
  ),
258
406
  inventory,
259
407
  };
408
+ model.enforcementState = buildEnforcementState(root, { ...model, ci });
409
+ model.enforcementLadder.ciMerge.requiredStatus = model.enforcementState.ciMerge.required;
410
+ return model;
260
411
  }
@@ -17,7 +17,7 @@ function installToolsForHost(activeHost) {
17
17
 
18
18
  export function detectWritePathCapabilities(root, explicitHost, attempt) {
19
19
  const model = buildWritePathCapabilityModel(root, explicitHost, attempt);
20
- const { activeHost, support, capabilities, capabilityEvidence, enforcementLadder, inventory } = model;
20
+ const { activeHost, support, capabilities, capabilityEvidence, enforcementLadder, enforcementState, inventory } = model;
21
21
  const hardWrite = capabilities['hard-write'];
22
22
  const advisoryWrite = capabilities['advisory-write'];
23
23
  const repairPayload = capabilities['repair-payload'];
@@ -78,7 +78,7 @@ export function detectWritePathCapabilities(root, explicitHost, attempt) {
78
78
  'not equivalent to Claude/Grok PreToolUse hard-write + repair). ' +
79
79
  'The hard merge backstop is CI --strict-merge plus a required status check.'
80
80
  : `Active host ${activeHost} has advisory prepare-write/autoPatch tools, ` +
81
- 'but no hard write boundary; the CI check can still reject the change before merge.';
81
+ 'but no hard write boundary; CI can report failure, while merge blocking requires provider policy.';
82
82
  gap = {
83
83
  id: 'write-path-mcp-only',
84
84
  severity: 'info',
@@ -99,6 +99,7 @@ export function detectWritePathCapabilities(root, explicitHost, attempt) {
99
99
  capabilities,
100
100
  capabilityEvidence,
101
101
  enforcementLadder,
102
+ enforcementState,
102
103
  inventory,
103
104
  // Compatibility projection for existing doctor/API consumers.
104
105
  mode,