arkgate 3.0.3 → 3.0.5

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.
@@ -3,7 +3,8 @@
3
3
  */
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { codexPromptsDir } from './codex-home.mjs';
6
+ import { arkCommand } from '../ark-shared.mjs';
7
+ import { codexPromptsDir, codexSkillsDir } from './codex-home.mjs';
7
8
  import { __packageRoot, isCompactRouterAgentsContent, readJson } from './gate-files.mjs';
8
9
 
9
10
  export function normalizeToolsList(tools) {
@@ -43,10 +44,11 @@ export function detectActiveAgentHost(env = process.env) {
43
44
  .toLowerCase();
44
45
  if (explicit) return explicit;
45
46
 
46
- // Grok / xAI Build
47
+ // Grok / xAI Build (include GROK_AGENT — common session signal missing in older detect)
47
48
  if (
48
49
  envTruthy(env.GROK_BUILD) ||
49
50
  envTruthy(env.XAI_GROK) ||
51
+ envTruthy(env.GROK_AGENT) ||
50
52
  env.GROK_WORKSPACE_ROOT ||
51
53
  env.GROK_SESSION_ID
52
54
  ) {
@@ -143,13 +145,19 @@ export const KNOWN_TOOLS = [
143
145
  ];
144
146
 
145
147
  // One canonical markdown per skill (templates/skills/*.md, shipped in the npm
146
- // package); installed into each tool's slash-command location. The YAML
147
- // frontmatter (name/description) is understood or harmlessly ignored by every
148
- // host. Kiro has no command mechanism — its steering rule file is the only gate.
148
+ // package); installed into each tool's slash-command / skill-catalog location.
149
+ // The YAML frontmatter (name/description) is understood or harmlessly ignored
150
+ // by every host. Kiro has no command mechanism — its steering rule file is the
151
+ // only gate.
152
+ //
153
+ // Codex: discovers Agent Skills directories with SKILL.md — repo path is the
154
+ // official `.agents/skills/<name>/SKILL.md` (not dead `.codex/prompts/*.md`).
155
+ // Home install uses `$CODEX_HOME/skills/<name>/SKILL.md` via --codex-home.
149
156
  export const SKILL_TOOL_TARGETS = {
150
157
  claude: (name) => `.claude/skills/${name}/SKILL.md`,
151
158
  cursor: (name) => `.cursor/commands/${name}.md`,
152
- codex: (name) => `.codex/prompts/${name}.md`,
159
+ // Official Codex REPO skill scope (Agent Skills standard).
160
+ codex: (name) => `.agents/skills/${name}/SKILL.md`,
153
161
  // Grok Build: project skills at .grok/skills/<name>/SKILL.md (slash-invocable).
154
162
  grok: (name) => `.grok/skills/${name}/SKILL.md`,
155
163
  windsurf: (name) => `.windsurf/workflows/${name}.md`,
@@ -253,6 +261,122 @@ export function skillTemplateNames() {
253
261
  .map((entry) => path.basename(entry.name, '.md'));
254
262
  }
255
263
 
264
+ /**
265
+ * Count present / stale / legacy-only skill files for one catalog root.
266
+ * @param {string[]} skillNames
267
+ * @param {(name: string) => string} skillFile path builder
268
+ * @param {string|null} packageVersion
269
+ * @param {{ legacyFile?: (name: string) => string }} [opts]
270
+ */
271
+ export function assessSkillCatalogParity(skillNames, skillFile, packageVersion, opts = {}) {
272
+ const expectedCount = skillNames.length;
273
+ const present = [];
274
+ let stale = 0;
275
+ for (const name of skillNames) {
276
+ const file = skillFile(name);
277
+ if (!fs.existsSync(file)) continue;
278
+ present.push(name);
279
+ if (packageVersion) {
280
+ const installed = installedSkillVersion(file);
281
+ if (installed === null || isVersionOlder(installed, packageVersion)) stale += 1;
282
+ }
283
+ }
284
+ let legacyCount = 0;
285
+ if (typeof opts.legacyFile === 'function') {
286
+ for (const name of skillNames) {
287
+ if (fs.existsSync(opts.legacyFile(name))) legacyCount += 1;
288
+ }
289
+ }
290
+ const presentCount = present.length;
291
+ const missing = expectedCount - presentCount;
292
+ const legacyPromptsOnly = presentCount === 0 && legacyCount > 0;
293
+ const hasLegacyPrompts = legacyCount > 0;
294
+ const ok = missing === 0 && stale === 0 && !legacyPromptsOnly;
295
+ return {
296
+ ok,
297
+ missing,
298
+ stale,
299
+ presentCount,
300
+ expectedCount,
301
+ packageVersion: packageVersion ?? null,
302
+ legacyPromptsOnly,
303
+ hasLegacyPrompts,
304
+ legacyCount,
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Repo + home Codex skill parity against the shipping package skill set.
310
+ * Producer trees (templates/skills) and projects without AGENTS.md return null.
311
+ *
312
+ * @param {string} root
313
+ * @returns {null | {
314
+ * packageVersion: string|null,
315
+ * expectedCount: number,
316
+ * repo: object,
317
+ * home: object,
318
+ * skillsDir: string,
319
+ * promptsDir: string,
320
+ * needsAttention: boolean,
321
+ * homeNeedsAttention: boolean,
322
+ * repoNeedsAttention: boolean,
323
+ * }}
324
+ */
325
+ export function assessCodexSkillParity(root) {
326
+ if (!fs.existsSync(path.join(root, 'AGENTS.md'))) return null;
327
+ if (fs.existsSync(path.join(root, 'templates', 'skills'))) return null;
328
+ const skillNames = skillTemplateNames();
329
+ if (skillNames.length === 0) return null;
330
+
331
+ const packageVersion = arkPackageVersion();
332
+ const skillsDir = codexSkillsDir();
333
+ const promptsDir = codexPromptsDir();
334
+ const repoSkill = (name) => path.join(root, SKILL_TOOL_TARGETS.codex(name));
335
+ const repoLegacy = (name) => path.join(root, '.codex', 'prompts', `${name}.md`);
336
+ const homeSkill = (name) => path.join(skillsDir, name, 'SKILL.md');
337
+ const homeLegacy = (name) => path.join(promptsDir, `${name}.md`);
338
+
339
+ const repo = assessSkillCatalogParity(skillNames, repoSkill, packageVersion, {
340
+ legacyFile: repoLegacy,
341
+ });
342
+ const home = assessSkillCatalogParity(skillNames, homeSkill, packageVersion, {
343
+ legacyFile: homeLegacy,
344
+ });
345
+
346
+ // Repo catalog matters when .codex is present (Codex host adopted) or repo skills/prompts exist.
347
+ const repoInPlay =
348
+ fs.existsSync(path.join(root, '.codex')) ||
349
+ repo.presentCount > 0 ||
350
+ repo.hasLegacyPrompts;
351
+ // Home is "in play" only when ark skills or legacy prompts were actually installed there
352
+ // (empty $CODEX_HOME/skills is optional multi-project — not debt).
353
+ const homeInPlay = home.presentCount > 0 || home.hasLegacyPrompts;
354
+
355
+ if (!repoInPlay && !homeInPlay) return null;
356
+
357
+ const repoNeedsAttention =
358
+ repoInPlay && (repo.missing > 0 || repo.stale > 0 || repo.legacyPromptsOnly);
359
+ const homeNeedsAttention =
360
+ homeInPlay && (home.missing > 0 || home.stale > 0 || home.legacyPromptsOnly);
361
+
362
+ return {
363
+ packageVersion,
364
+ expectedCount: skillNames.length,
365
+ repo: { ...repo, inPlay: repoInPlay },
366
+ home: {
367
+ ...home,
368
+ inPlay: homeInPlay,
369
+ skillsDir,
370
+ promptsDir,
371
+ },
372
+ skillsDir,
373
+ promptsDir,
374
+ repoNeedsAttention,
375
+ homeNeedsAttention,
376
+ needsAttention: repoNeedsAttention || homeNeedsAttention,
377
+ };
378
+ }
379
+
256
380
  // A normal ark-check run is the reliable discovery point for new /ark-* skills.
257
381
  // Ark ships no install lifecycle script (a postinstall banner would be blocked by
258
382
  // modern package managers' script-approval policy anyway, so careful users never
@@ -262,24 +386,94 @@ export function skillTemplateNames() {
262
386
  // Advisory only — never affects the exit code. Copilot has no reliable directory
263
387
  // signal, so it is not auto-detected (explicit --tools only), matching resolveTools.
264
388
  export function detectCodexHomeGap(root) {
265
- if (!fs.existsSync(path.join(root, 'AGENTS.md'))) return null;
266
- if (fs.existsSync(path.join(root, 'templates', 'skills'))) return null;
267
- const skillNames = skillTemplateNames();
268
- if (skillNames.length === 0) return null;
269
- const dir = codexPromptsDir();
270
- if (!fs.existsSync(dir)) return null;
271
- const present = skillNames.filter((name) => fs.existsSync(path.join(dir, `${name}.md`)));
272
- if (present.length === 0) return null; // Codex home never set up for Ark — don't nag.
273
- const version = arkPackageVersion();
274
- const missing = skillNames.length - present.length;
275
- let stale = 0;
276
- if (version) {
277
- for (const name of present) {
278
- const installed = installedSkillVersion(path.join(dir, `${name}.md`));
279
- if (installed === null || isVersionOlder(installed, version)) stale += 1;
389
+ const parity = assessCodexSkillParity(root);
390
+ if (!parity || !parity.homeNeedsAttention) return null;
391
+ const { home, packageVersion, expectedCount, skillsDir } = parity;
392
+ return {
393
+ missing: home.missing,
394
+ stale: home.stale,
395
+ legacyPromptsOnly: Boolean(home.legacyPromptsOnly),
396
+ hasLegacyPrompts: Boolean(home.hasLegacyPrompts),
397
+ presentCount: home.presentCount,
398
+ expectedCount,
399
+ packageVersion,
400
+ skillsDir,
401
+ };
402
+ }
403
+
404
+ /**
405
+ * Repo-side Codex gaps: missing/stale .agents/skills or legacy .codex/prompts only.
406
+ * @param {string} root
407
+ * @returns {null | { missing: number, stale: number, legacyPromptsOnly: boolean, hasLegacyPrompts: boolean, presentCount: number, expectedCount: number, packageVersion: string|null }}
408
+ */
409
+ export function detectCodexRepoSkillGap(root) {
410
+ const parity = assessCodexSkillParity(root);
411
+ if (!parity || !parity.repoNeedsAttention) return null;
412
+ const { repo, packageVersion, expectedCount } = parity;
413
+ return {
414
+ missing: repo.missing,
415
+ stale: repo.stale,
416
+ legacyPromptsOnly: Boolean(repo.legacyPromptsOnly),
417
+ hasLegacyPrompts: Boolean(repo.hasLegacyPrompts),
418
+ presentCount: repo.presentCount,
419
+ expectedCount,
420
+ packageVersion,
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Skill names referenced as `/ark-*` in AGENTS.md (or any instruction text).
426
+ * @param {string} text
427
+ * @returns {string[]}
428
+ */
429
+ export function agentsMdSkillRefs(text) {
430
+ if (!text || typeof text !== 'string') return [];
431
+ const refs = new Set();
432
+ const re = /\/(ark-[a-z0-9-]+)/g;
433
+ let match;
434
+ while ((match = re.exec(text)) !== null) refs.add(match[1]);
435
+ return [...refs].sort();
436
+ }
437
+
438
+ /**
439
+ * Verify that every `/ark-*` skill referenced by AGENTS.md (and known to this
440
+ * package) is present in each selected host's skill catalog path.
441
+ *
442
+ * Compact routers intentionally omit `/ark-*` — they verify as ok with no checks.
443
+ *
444
+ * @param {string} root
445
+ * @param {Iterable<string>} tools
446
+ * @param {{ skillNames?: string[], agentsText?: string }} [options]
447
+ * @returns {{ ok: boolean, missing: Array<{ tool: string, name: string, path: string }>, referenced: string[], checkedTools: string[], compact?: boolean }}
448
+ */
449
+ export function verifyHostSkillCatalog(root, tools, options = {}) {
450
+ const skillNames = new Set(options.skillNames ?? skillTemplateNames());
451
+ let agentsText = options.agentsText;
452
+ if (agentsText == null) {
453
+ try {
454
+ agentsText = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8');
455
+ } catch {
456
+ return { ok: true, missing: [], referenced: [], checkedTools: [] };
457
+ }
458
+ }
459
+ if (isCompactRouterAgentsContent(agentsText)) {
460
+ return { ok: true, missing: [], referenced: [], checkedTools: [], compact: true };
461
+ }
462
+ const referenced = agentsMdSkillRefs(agentsText).filter((name) => skillNames.has(name));
463
+ const missing = [];
464
+ const checkedTools = [];
465
+ for (const tool of tools) {
466
+ const target = SKILL_TOOL_TARGETS[tool];
467
+ if (!target) continue;
468
+ checkedTools.push(tool);
469
+ for (const name of referenced) {
470
+ const relativePath = target(name);
471
+ if (!fs.existsSync(path.join(root, relativePath))) {
472
+ missing.push({ tool, name, path: relativePath });
473
+ }
280
474
  }
281
475
  }
282
- return missing > 0 || stale > 0 ? { missing, stale } : null;
476
+ return { ok: missing.length === 0, missing, referenced, checkedTools };
283
477
  }
284
478
 
285
479
  export function detectSkillGaps(root) {
@@ -324,7 +518,94 @@ export function detectSkillGaps(root) {
324
518
  if (installed === null || isVersionOlder(installed, version)) stale += 1;
325
519
  }
326
520
  }
327
- if (missing > 0 || stale > 0) gaps.push({ tool, missing, stale });
521
+ let legacyPromptsOnly = false;
522
+ let hasLegacyPrompts = false;
523
+ if (tool === 'codex') {
524
+ const legacyCount = skillNames.filter((name) =>
525
+ fs.existsSync(path.join(root, '.codex', 'prompts', `${name}.md`))
526
+ ).length;
527
+ hasLegacyPrompts = legacyCount > 0;
528
+ // Flat prompts without any SKILL.md catalog entries are not loadable.
529
+ legacyPromptsOnly = hasLegacyPrompts && missing === skillNames.length;
530
+ }
531
+ if (missing > 0 || stale > 0 || legacyPromptsOnly) {
532
+ gaps.push({
533
+ tool,
534
+ missing,
535
+ stale,
536
+ ...(legacyPromptsOnly ? { legacyPromptsOnly: true } : {}),
537
+ ...(hasLegacyPrompts ? { hasLegacyPrompts: true } : {}),
538
+ });
539
+ }
328
540
  }
329
541
  return gaps;
330
542
  }
543
+
544
+ /**
545
+ * Human-facing skill / Codex catalog gap lines for ark-check (non-JSON).
546
+ * @param {string} root
547
+ * @param {{ skillGaps: object[], codexHomeGap: object|null, codexRepoSkillGap: object|null, codexSessionActive: boolean, color: { dim: Function, yellow: Function } }} opts
548
+ */
549
+ export function printSkillAndCodexGapHints(root, opts) {
550
+ const { skillGaps, codexHomeGap, codexRepoSkillGap, codexSessionActive, color } = opts;
551
+ if (skillGaps?.length > 0) {
552
+ const legacyCodex = skillGaps.some((gap) => gap.tool === 'codex' && gap.legacyPromptsOnly);
553
+ // Report Codex legacy separately; never suppress missing/stale for other hosts.
554
+ const remaining = skillGaps.filter((gap) => !(gap.tool === 'codex' && gap.legacyPromptsOnly));
555
+ const missingTotal = remaining.reduce((sum, gap) => sum + gap.missing, 0);
556
+ const staleTotal = remaining.reduce((sum, gap) => sum + gap.stale, 0);
557
+ const tools = remaining.map((gap) => gap.tool).join(', ');
558
+ if (legacyCodex) {
559
+ console.log(
560
+ color.yellow(
561
+ 'Codex has legacy flat .codex/prompts/ark-*.md only — those are not loadable as skills. ' +
562
+ `Install the real catalog: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --tools codex --force')}`
563
+ )
564
+ );
565
+ }
566
+ if (missingTotal > 0) {
567
+ console.log(
568
+ color.dim(
569
+ `${missingTotal} /ark-* skill(s) not installed for ${tools} (this Ark version ships them). ` +
570
+ `Install: ${arkCommand(root, 'ark-check', '--install-agent-gates')}`
571
+ )
572
+ );
573
+ }
574
+ if (staleTotal > 0) {
575
+ console.log(
576
+ color.dim(
577
+ `${staleTotal} /ark-* skill(s) outdated for ${tools} (this Ark ships newer versions). ` +
578
+ `Refresh: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --force')}`
579
+ )
580
+ );
581
+ }
582
+ }
583
+ if (codexHomeGap) {
584
+ const parts = [];
585
+ if (codexHomeGap.legacyPromptsOnly) parts.push('legacy-prompts-only');
586
+ if (codexHomeGap.missing > 0) parts.push(`${codexHomeGap.missing} missing`);
587
+ if (codexHomeGap.stale > 0) parts.push(`${codexHomeGap.stale} outdated`);
588
+ const deferred = !codexSessionActive;
589
+ const deferredNote = deferred
590
+ ? ' Deferred unless you use Codex — not a blocker for Grok/Claude/Cursor. '
591
+ : ' ';
592
+ const msg =
593
+ `Codex home skill catalog (${codexSkillsDir()}) behind this Ark (${parts.join(', ')}).` +
594
+ deferredNote +
595
+ `Catalog is $CODEX_HOME/skills/<name>/SKILL.md (not flat prompts). ` +
596
+ `When using Codex: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --codex-home --force')}`;
597
+ console.log(deferred ? color.dim(msg) : color.yellow(msg));
598
+ }
599
+ if (codexRepoSkillGap && codexSessionActive) {
600
+ const parts = [];
601
+ if (codexRepoSkillGap.legacyPromptsOnly) parts.push('legacy-prompts-only');
602
+ if (codexRepoSkillGap.missing > 0) parts.push(`${codexRepoSkillGap.missing} missing`);
603
+ if (codexRepoSkillGap.stale > 0) parts.push(`${codexRepoSkillGap.stale} outdated`);
604
+ console.log(
605
+ color.yellow(
606
+ `Codex repo skill catalog (.agents/skills) needs refresh (${parts.join(', ')}). ` +
607
+ `Fix: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --tools codex --force')}`
608
+ )
609
+ );
610
+ }
611
+ }
@@ -48,6 +48,30 @@ export function detectPreCommitArk(root) {
48
48
  return { present, arkAware, path: hit };
49
49
  }
50
50
 
51
+ /**
52
+ * Classify ark-check flags in a workflow or package script body.
53
+ * CLI: `--strict` and `--strict-merge` both set strictConfig + requireGates (fail-closed).
54
+ * `--strict-config` alone does not require gate files.
55
+ *
56
+ * @param {string} text
57
+ * @returns {{ hasFailClosedFlag: boolean, hasStrictConfigOnly: boolean, hasStrictFlag: boolean }}
58
+ */
59
+ export function classifyArkCheckFlags(text) {
60
+ if (!text || typeof text !== 'string') {
61
+ return { hasFailClosedFlag: false, hasStrictConfigOnly: false, hasStrictFlag: false };
62
+ }
63
+ const hasStrictMerge = /--strict-merge\b/.test(text);
64
+ const hasRequireGates = /--require-gates\b/.test(text);
65
+ // 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);
68
+ const hasFailClosedFlag = hasStrictMerge || hasRequireGates || hasBareStrict;
69
+ const hasStrictConfig = /--strict-config\b/.test(text);
70
+ const hasStrictConfigOnly = hasStrictConfig && !hasFailClosedFlag;
71
+ const hasStrictFlag = hasFailClosedFlag || hasStrictConfigOnly;
72
+ return { hasFailClosedFlag, hasStrictConfigOnly, hasStrictFlag };
73
+ }
74
+
51
75
  /**
52
76
  * @param {string} root
53
77
  * @returns {{
@@ -56,6 +80,9 @@ export function detectPreCommitArk(root) {
56
80
  * arkWorkflowFiles: string[],
57
81
  * hasArkCheckWorkflow: boolean,
58
82
  * hasStrictFlag: boolean,
83
+ * hasFailClosedFlag: boolean,
84
+ * hasStrictConfigOnly: boolean,
85
+ * failClosed: boolean,
59
86
  * hasArchitectureJobName: boolean,
60
87
  * }}
61
88
  */
@@ -67,6 +94,9 @@ export function detectCiEnforcement(root) {
67
94
  arkWorkflowFiles: [],
68
95
  hasArkCheckWorkflow: false,
69
96
  hasStrictFlag: false,
97
+ hasFailClosedFlag: false,
98
+ hasStrictConfigOnly: false,
99
+ failClosed: false,
70
100
  hasArchitectureJobName: false,
71
101
  };
72
102
  if (!out.hasWorkflowsDir) return out;
@@ -77,6 +107,19 @@ export function detectCiEnforcement(root) {
77
107
  return out;
78
108
  }
79
109
  out.workflowFiles = files;
110
+
111
+ let checkArchScript = '';
112
+ try {
113
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
114
+ checkArchScript =
115
+ typeof pkg?.scripts?.['check:architecture'] === 'string'
116
+ ? pkg.scripts['check:architecture']
117
+ : '';
118
+ } catch {
119
+ checkArchScript = '';
120
+ }
121
+ const scriptFlags = classifyArkCheckFlags(checkArchScript);
122
+
80
123
  for (const f of files) {
81
124
  let text = '';
82
125
  try {
@@ -92,9 +135,19 @@ export function detectCiEnforcement(root) {
92
135
  if (mentionsArk) {
93
136
  out.hasArkCheckWorkflow = true;
94
137
  out.arkWorkflowFiles.push(`.github/workflows/${f}`);
95
- if (/--strict\b/.test(text) || /check:architecture/.test(text)) {
138
+ const flags = classifyArkCheckFlags(text);
139
+ const viaScript = /check:architecture/.test(text) && scriptFlags.hasFailClosedFlag;
140
+ if (flags.hasFailClosedFlag || viaScript) {
141
+ out.hasFailClosedFlag = true;
142
+ out.failClosed = true;
143
+ out.hasStrictFlag = true;
144
+ } else if (flags.hasStrictConfigOnly) {
145
+ out.hasStrictConfigOnly = true;
146
+ out.hasStrictFlag = true;
147
+ } else if (flags.hasStrictFlag) {
96
148
  out.hasStrictFlag = true;
97
149
  }
150
+ // check:architecture without fail-closed flags in the script is NOT fail-closed.
98
151
  if (/architecture|ark-check|arkgate-check/i.test(f) || /name:\s*.*ark/i.test(text)) {
99
152
  out.hasArchitectureJobName = true;
100
153
  }
@@ -339,18 +392,14 @@ export function collectWeakestLinkGaps(root, opts = {}) {
339
392
  'CI workflows exist but none run ark-check / arkgate-check / check:architecture',
340
393
  fix: arkCommand(root, 'ark-check', '--install-agent-gates'),
341
394
  });
342
- } else if (
343
- adopted &&
344
- !isProducer &&
345
- ci.hasArkCheckWorkflow &&
346
- !ci.hasStrictFlag
347
- ) {
395
+ } else if (adopted && !isProducer && ci.hasArkCheckWorkflow && !ci.failClosed) {
348
396
  gaps.push({
349
- id: 'enforcement-ci-not-strict',
350
- severity: 'info',
351
- message:
352
- 'Architecture CI job found but does not pass --strict / check:architecture (weaker than recommended)',
353
- fix: 'Add --strict (or npm run check:architecture) to the architecture workflow step',
397
+ id: 'enforcement-ci-not-fail-closed',
398
+ severity: 'warn',
399
+ message: ci.hasStrictConfigOnly
400
+ ? 'Architecture CI uses --strict-config only (config coverage without gate-file presence). Prefer the fail-closed profile.'
401
+ : 'Architecture CI job found but does not use the fail-closed profile (--strict-merge / --strict / --require-gates)',
402
+ fix: 'ark-check --root . --config ark.config.json --strict-merge --baseline .ark-baseline.json',
354
403
  });
355
404
  }
356
405
 
@@ -121,7 +121,9 @@ function hostRecord(hard, advisory, repair, merge) {
121
121
  }
122
122
 
123
123
  export function detectWritePathInventory(root) {
124
- const merge = detectCiEnforcement(root).arkWorkflowFiles;
124
+ // Merge-gate evidence only when CI uses the fail-closed profile (not bare ark-check).
125
+ const ci = detectCiEnforcement(root);
126
+ const merge = ci.failClosed ? ci.arkWorkflowFiles : [];
125
127
  const claudeHook = hookEvidence(root, '.claude/settings.json');
126
128
  const grokHook = hookEvidence(root, '.grok/hooks/ark-write-gate.json');
127
129
  const hosts = {
@@ -30,21 +30,32 @@ export function detectWritePathCapabilities(root, explicitHost) {
30
30
 
31
31
  const tools = installToolsForHost(activeHost);
32
32
  let gap = null;
33
+ // Repo inventory (any host) can show hard/advisory write while activeHost is
34
+ // unknown (plain shell / `npx ark-check --report` outside an agent session).
35
+ // Session projection stays mode=none (other hosts' hooks are not a guarantee for
36
+ // this process) — but do not open an adoption gap: gates exist on disk.
37
+ const inventoryHasWriteBoundary =
38
+ Boolean(inventory?.capabilities?.['hard-write']) ||
39
+ Boolean(inventory?.capabilities?.['advisory-write']);
33
40
  if (mode === 'none') {
34
- gap = {
35
- id: 'write-path-none',
36
- severity: 'warn',
37
- message:
38
- `Active host ${activeHost} has no hard write boundary or advisory Ark MCP. ` +
39
- (capabilities['merge-gate']
40
- ? 'The CI check remains separate and does not block local writes.'
41
- : 'No Ark CI check was detected either.'),
42
- fix: arkCommand(
43
- root,
44
- 'ark-check',
45
- `--install-agent-gates --tools ${tools}`
46
- ),
47
- };
41
+ if (activeHost === 'unknown' && inventoryHasWriteBoundary) {
42
+ gap = null;
43
+ } else {
44
+ gap = {
45
+ id: 'write-path-none',
46
+ severity: 'warn',
47
+ message:
48
+ `Active host ${activeHost} has no hard write boundary or advisory Ark MCP. ` +
49
+ (capabilities['merge-gate']
50
+ ? 'The CI check remains separate and does not block local writes.'
51
+ : 'No Ark CI check was detected either.'),
52
+ fix: arkCommand(
53
+ root,
54
+ 'ark-check',
55
+ `--install-agent-gates --tools ${tools}`
56
+ ),
57
+ };
58
+ }
48
59
  } else if (mode === 'reject-only') {
49
60
  gap = {
50
61
  id: 'write-path-reject-only',
@@ -61,17 +72,23 @@ export function detectWritePathCapabilities(root, explicitHost) {
61
72
  ),
62
73
  };
63
74
  } else if (mode === 'mcp-only') {
75
+ const codexHonesty =
76
+ activeHost === 'codex'
77
+ ? 'Codex local write is advisory (MCP + best-effort hooks.json — not a hard boundary; ' +
78
+ 'not equivalent to Claude/Grok PreToolUse hard-write + repair). ' +
79
+ 'The hard merge backstop is CI --strict-merge plus a required status check.'
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.';
64
82
  gap = {
65
83
  id: 'write-path-mcp-only',
66
84
  severity: 'info',
67
- message:
68
- `Active host ${activeHost} has advisory prepare-write/autoPatch tools, ` +
69
- 'but no hard write boundary; the CI check can still reject the change before merge.',
70
- fix: arkCommand(
71
- root,
72
- 'ark-check',
73
- `--install-agent-gates --tools ${tools}`
74
- ),
85
+ host: activeHost,
86
+ message: codexHonesty,
87
+ fix:
88
+ activeHost === 'codex'
89
+ ? 'Keep CI on --strict-merge and require the ark-check status on the default branch; ' +
90
+ `refresh Codex MCP/skills with ${arkCommand(root, 'ark-check', '--install-agent-gates --tools codex')}`
91
+ : arkCommand(root, 'ark-check', `--install-agent-gates --tools ${tools}`),
75
92
  };
76
93
  }
77
94
 
package/dist/index.cjs CHANGED
@@ -50,7 +50,7 @@ __export(gate_exports, {
50
50
  module.exports = __toCommonJS(gate_exports);
51
51
 
52
52
  // src/version.ts
53
- var version = "3.0.3";
53
+ var version = "3.0.5";
54
54
 
55
55
  // src/domain/adapterContract.ts
56
56
  var ARK_ANALYSIS_RESULT_SCHEMA_VERSION = "1.0";
package/dist/index.d.cts CHANGED
@@ -2,7 +2,7 @@ import { a as ArkConfigRule, b as ArkConfigLayer, A as ArkConfig, c as ArkConfig
2
2
  export { d as ARK_CONFIG_SCHEMA, e as ARK_CONFIG_SCHEMA_VERSION, l as loadArkConfigContract, p as parseArkConfigJson } from './configContract-BxSIwVRo.cjs';
3
3
 
4
4
  /** ArkGate library version — single source of truth. */
5
- declare const version = "3.0.3";
5
+ declare const version = "3.0.5";
6
6
 
7
7
  /** Versioned public result contract shared by every ArkGate enforcement adapter. */
8
8
  declare const ARK_ANALYSIS_RESULT_SCHEMA_VERSION: "1.0";
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import { a as ArkConfigRule, b as ArkConfigLayer, A as ArkConfig, c as ArkConfig
2
2
  export { d as ARK_CONFIG_SCHEMA, e as ARK_CONFIG_SCHEMA_VERSION, l as loadArkConfigContract, p as parseArkConfigJson } from './configContract-BxSIwVRo.js';
3
3
 
4
4
  /** ArkGate library version — single source of truth. */
5
- declare const version = "3.0.3";
5
+ declare const version = "3.0.5";
6
6
 
7
7
  /** Versioned public result contract shared by every ArkGate enforcement adapter. */
8
8
  declare const ARK_ANALYSIS_RESULT_SCHEMA_VERSION: "1.0";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var version = "3.0.3";
2
+ var version = "3.0.5";
3
3
 
4
4
  // src/domain/adapterContract.ts
5
5
  var ARK_ANALYSIS_RESULT_SCHEMA_VERSION = "1.0";
@@ -307,7 +307,7 @@ npx arkgate-check --install-agent-gates --tools claude,cursor,codex,grok
307
307
  |------|-----------------|-------------|
308
308
  | Claude Code | `.claude/settings.json` hook + `.mcp.json` / `claude mcp add` | `.claude/skills/<name>/SKILL.md` |
309
309
  | Cursor | `.cursor/mcp.json` + `.cursor/rules/ark.mdc` | `.cursor/commands/` |
310
- | OpenAI Codex | `$CODEX_HOME/config.toml` (global; absolute `--root`; multi-project → secondary `ark_<slug>` unless `--force`; doctor defers non-temp home gaps when session host ≠ Codex — see [ai-gates.md](ai-gates.md)) | `$CODEX_HOME/prompts` (`--codex-home`; fix when using Codex) |
310
+ | OpenAI Codex | `$CODEX_HOME/config.toml` (global; absolute `--root`; multi-project → secondary `ark_<slug>` unless `--force`; doctor defers non-temp home gaps when session host ≠ Codex — see [ai-gates.md](ai-gates.md)) | **Repo:** `.agents/skills/<name>/SKILL.md`; **home:** `$CODEX_HOME/skills/<name>/SKILL.md` (`--codex-home`) |
311
311
  | **Grok Build** | `.grok/hooks/ark-write-gate.json` + `.grok/config.toml` / `.mcp.json` | `.grok/skills/<name>/SKILL.md` |
312
312
 
313
313
  This is a path reference, not a guarantee table. Full copy-paste setups: