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.
@@ -11,6 +11,13 @@ import {
11
11
  resolveOperatingMode,
12
12
  } from '../ark-shared.mjs';
13
13
  import { collectAdoptionGaps, arkCheckCommand } from './agent-gates.mjs';
14
+ import { CORE_LAYER_NAMES } from './core-layers.mjs';
15
+ import {
16
+ renderBaselineSignalLegend,
17
+ renderDesignCleanNote,
18
+ renderDesignDepthStrip,
19
+ renderWritePathAdoptionBlock,
20
+ } from './html-report-depth.mjs';
14
21
  import { FIX_HINTS } from './violations.mjs';
15
22
 
16
23
  export function detectEnforcement(root) {
@@ -58,6 +65,53 @@ export function htmlEscape(value) {
58
65
  .replace(/"/g, '"');
59
66
  }
60
67
 
68
+ /**
69
+ * KPI tile with plain-language hint (visible micro-copy + native tooltip).
70
+ * Helps newcomers read the showcase without memorizing Ark jargon.
71
+ *
72
+ * @param {string|number} value
73
+ * @param {string} label short metric name
74
+ * @param {string} hint one-sentence meaning
75
+ */
76
+ export function metricKpi(value, label, hint) {
77
+ const v = htmlEscape(String(value));
78
+ const l = htmlEscape(label);
79
+ const h = htmlEscape(hint);
80
+ return `<div class="kpi" title="${h}" aria-label="${l}: ${v}. ${h}">
81
+ <b>${v}</b>
82
+ <span>${l}</span>
83
+ <em class="kpi-hint">${h}</em>
84
+ </div>`;
85
+ }
86
+
87
+ /** Baseline policy signal → human meaning (adoption card). */
88
+ export function baselineSignalHint(signal) {
89
+ switch (String(signal || '')) {
90
+ case 'keep-empty':
91
+ return 'Baseline file exists and freezes 0 keys — every violation is active (honest green).';
92
+ case 'active-ratchet':
93
+ return 'Baseline freezes known debt keys; new distinct violations still fail the check.';
94
+ case 'absent':
95
+ return 'No .ark-baseline.json — all findings are active (or you have not adopted a freeze file).';
96
+ default:
97
+ return 'How frozen debt is handled relative to active architecture violations.';
98
+ }
99
+ }
100
+
101
+ /** Operating mode badge tooltip. */
102
+ export function modeBadgeHint(mode) {
103
+ switch (String(mode || '').toLowerCase()) {
104
+ case 'enforce':
105
+ return 'Contract matches the tree: cores are required where populated, coverage is honest, gates can hold the line.';
106
+ case 'adapt':
107
+ return 'Contract is live but still aligning (optional cores with files, empty cores, or presentation-bag false green).';
108
+ case 'suggest':
109
+ return 'Starter shape — expand layers and raise governed coverage as the codebase grows.';
110
+ default:
111
+ return 'Operating mode for co-pilot surfaces (suggest · adapt · enforce).';
112
+ }
113
+ }
114
+
61
115
  /** Directory for origin / latest / history architecture report snapshots. */
62
116
  const ARK_REPORTS_DIR = path.join('.ark', 'reports');
63
117
  const ARK_REPORT_HISTORY_MAX = 20;
@@ -159,7 +213,10 @@ export function computeReportFitness({ coverage, violations, ok, enforcement, co
159
213
  const presentationRow = (coverage?.layers ?? []).find(
160
214
  (r) => r.name === 'PresentationAdapters'
161
215
  );
216
+ // Same honesty gate as doctor (`mcp-adoption` coreOptional): only the four cores
217
+ // matter. Secondary optional layers with files must not force ADAPT on the HTML report.
162
218
  const coreOptionalWithFiles = (config?.layers ?? []).filter((layer) => {
219
+ if (!CORE_LAYER_NAMES.has(layer.name)) return false;
163
220
  if (layer.optional !== true) return false;
164
221
  const row = (coverage?.layers ?? []).find((r) => r.name === layer.name);
165
222
  return (row?.files ?? 0) > 0;
@@ -396,6 +453,8 @@ export function renderHtmlReport({
396
453
  currentSnapshot = null,
397
454
  originJustCreated = false,
398
455
  adoption = null,
456
+ /** Optional design-depth (doctor parity): designFitness, designSmells, pilotLoop, postGreenPath, goldenPattern */
457
+ designDepth = null,
399
458
  }) {
400
459
  const layers = Array.isArray(config.layers) ? config.layers : [];
401
460
  const rules = Array.isArray(config.rules) ? config.rules : [];
@@ -448,6 +507,29 @@ export function renderHtmlReport({
448
507
  } = fitness;
449
508
 
450
509
  const adoptionView = adoption || collectAdoptionGaps(root, config, coverage);
510
+ const depth = designDepth && typeof designDepth === 'object' ? designDepth : {};
511
+ const designFitness = depth.designFitness ?? null;
512
+ const designSmells = Array.isArray(depth.designSmells) ? depth.designSmells : [];
513
+ const designWeakBadge =
514
+ designFitness?.designWeak === true
515
+ ? ` <span class="badge design" title="Edges can be green while lived design residual remains (Shape). Not a FAIL.">design-weak</span>`
516
+ : '';
517
+ const designStripHtml =
518
+ renderDesignDepthStrip({
519
+ designFitness,
520
+ designSmells,
521
+ pilotLoop: depth.pilotLoop,
522
+ postGreenPath: depth.postGreenPath,
523
+ goldenPattern: depth.goldenPattern,
524
+ mode,
525
+ }) ||
526
+ renderDesignCleanNote({
527
+ designFitness,
528
+ ok,
529
+ mode,
530
+ });
531
+ const writePathHtml = renderWritePathAdoptionBlock(adoptionView.writePath);
532
+ const baselineLegendHtml = renderBaselineSignalLegend();
451
533
 
452
534
  // ── Senior diagnostics (coupling, purity, contract density) ──────────────
453
535
  const layerNames = ordered.map((l) => l.name);
@@ -818,9 +900,33 @@ export function renderHtmlReport({
818
900
  .score-cap { color: var(--dim); font-size: .85rem; margin: 0; }
819
901
  .kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: .65rem; margin: 1rem 0 0; }
820
902
  @media (max-width: 720px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
821
- .kpi { background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: .7rem .8rem; }
903
+ .kpi { background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: .7rem .8rem; cursor: help; }
822
904
  .kpi b { display: block; font-size: 1.25rem; letter-spacing: -0.02em; }
823
905
  .kpi span { color: var(--dim); font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; }
906
+ .kpi-hint {
907
+ display: block; margin-top: .4rem; color: var(--dim); font-size: .68rem; font-style: normal;
908
+ font-weight: 450; line-height: 1.35; letter-spacing: 0; text-transform: none; max-width: 16rem;
909
+ }
910
+ .score-parts span { cursor: help; border-bottom: 1px dotted color-mix(in srgb, var(--dim) 55%, transparent); }
911
+ .badge[title] { cursor: help; }
912
+ .badge.design {
913
+ background: color-mix(in srgb, var(--gold) 18%, transparent); color: var(--gold);
914
+ border-color: color-mix(in srgb, var(--gold) 40%, transparent);
915
+ }
916
+ .badge.design-ok {
917
+ background: color-mix(in srgb, var(--green) 16%, transparent); color: var(--green);
918
+ border-color: color-mix(in srgb, var(--green) 35%, transparent);
919
+ }
920
+ .design-strip { border-left: 3px solid var(--gold); }
921
+ .design-strip.is-clean { border-left-color: var(--green); }
922
+ .design-strip.has-smells { border-left-color: var(--gold); }
923
+ .design-head { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
924
+ .pilot-card {
925
+ margin-top: .35rem; padding: .75rem .9rem; border-radius: 12px;
926
+ background: var(--panel2); border: 1px solid var(--line);
927
+ }
928
+ .write-path-block { margin-top: .15rem; }
929
+ .baseline-legend summary { cursor: pointer; color: var(--dim); font-size: .84rem; }
824
930
  .section { margin-top: 1.35rem; }
825
931
  .grid-2 { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 1rem; }
826
932
  @media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
@@ -936,34 +1042,79 @@ export function renderHtmlReport({
936
1042
  <div class="hero">
937
1043
  <div class="card">
938
1044
  <div class="brand"><i></i> Ark architecture report</div>
939
- <h1>${esc(project)} <span class="badge ${status}">${status}</span> <span class="badge mode">${esc(modeLabel)}</span></h1>
940
- <p class="lede">${esc(modeBlurb)} One machine-readable contract · write gate · CI · optional runtime.</p>
1045
+ <h1>${esc(project)} <span class="badge ${status}" title="${status === 'PASS' ? 'Architecture check is green: 0 active violations against the contract.' : 'Architecture check failed: active violations remain (or the scan could not complete cleanly).'}">${status}</span> <span class="badge mode" title="${esc(modeBadgeHint(mode))}">${esc(modeLabel)}</span>${designWeakBadge}</h1>
1046
+ <p class="lede">${esc(modeBlurb)}${designFitness?.designWeak ? ' Design residual remains (see strip below) — not a FAIL.' : ''} One machine-readable contract · write gate · CI · optional runtime.</p>
941
1047
  <div class="kpis">
942
- <div class="kpi"><b>${esc(govLabel)}</b><span>Governed</span></div>
943
- <div class="kpi"><b>${layers.length}</b><span>Layers</span></div>
944
- <div class="kpi"><b>${gatesOn}/${enforcement.length}</b><span>Gates live</span></div>
945
- <div class="kpi"><b>${violations.length}${suppressed ? ` · ${suppressed}Δ` : ''}</b><span>Violations${suppressed ? ' · frozen' : ''}</span></div>
1048
+ ${metricKpi(
1049
+ govLabel,
1050
+ 'Governed',
1051
+ 'Share of scanned files assigned to a contract layer. 100% means every in-scope file has a home.'
1052
+ )}
1053
+ ${metricKpi(
1054
+ layers.length,
1055
+ 'Layers',
1056
+ 'How many architecture layers the contract defines (cores + optional product layers).'
1057
+ )}
1058
+ ${metricKpi(
1059
+ `${gatesOn}/${enforcement.length}`,
1060
+ 'Gates live',
1061
+ 'Write hook, CI workflow, ESLint plugin, and baseline file — how many enforcement points are actually present.'
1062
+ )}
1063
+ ${metricKpi(
1064
+ `${violations.length}${suppressed ? ` · ${suppressed}Δ` : ''}`,
1065
+ `Violations${suppressed ? ' · frozen' : ''}`,
1066
+ suppressed
1067
+ ? 'Active contract breaks right now; Δ = keys frozen in baseline (not failing until ratchet).'
1068
+ : 'Active contract breaks (layer imports, purity, etc.). Zero means edges match the rules.'
1069
+ )}
946
1070
  </div>
947
1071
  <p class="meta">${meta}</p>
948
1072
  ${skillsNote}
949
1073
  </div>
950
- <div class="card score-card">
1074
+ <div class="card score-card" title="Human fitness signal only — not a CI gate. Weighted blend of coverage, cleanliness, live gates, and rule density.">
951
1075
  <div class="score-ring ${scoreTone}"><div><div class="score-n">${score}</div><div class="dim" style="font-size:.72rem;letter-spacing:.08em;text-transform:uppercase">Ark score</div></div></div>
952
1076
  <p class="score-cap">${esc(scoreCaption)}</p>
953
- <p class="meta" style="margin-top:.65rem">Coverage ${scoreCoverage} · Clean ${scoreClean} · Gates ${scoreGates} · Rules ${scoreRules}</p>
1077
+ <p class="meta score-parts" style="margin-top:.65rem">
1078
+ <span title="0.4 weight — governed file percent (or 50 if coverage unknown).">${esc(`Coverage ${scoreCoverage}`)}</span>
1079
+ · <span title="0.3 weight — 100 with zero active violations; drops as violations pile up.">${esc(`Clean ${scoreClean}`)}</span>
1080
+ · <span title="0.2 weight — share of enforcement points that are present on disk (hook, CI, ESLint, baseline).">${esc(`Gates ${scoreGates}`)}</span>
1081
+ · <span title="0.1 weight — how dense the deny matrix is relative to layer pairs (stricter inward architecture scores higher).">${esc(`Rules ${scoreRules}`)}</span>
1082
+ </p>
954
1083
  </div>
955
1084
  </div>
956
1085
 
1086
+ ${designStripHtml}
1087
+
957
1088
  <div class="section card" id="adoption">
958
1089
  <h2>Adoption</h2>
959
1090
  <p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">
960
1091
  Co-pilot completeness — separate from the 0–100 fitness score above. Hosts, MCP health, origin snapshot, core optionality, baseline policy.
961
1092
  </p>
962
1093
  <div class="kpis" style="margin-bottom:.75rem">
963
- <div class="kpi"><b>${adoptionView.gaps.length === 0 ? 'OK' : adoptionView.gaps.length}</b><span>${adoptionView.gaps.length === 0 ? 'No adoption gaps' : 'Adoption gap(s)'}</span></div>
964
- <div class="kpi"><b>${adoptionView.originReport.present ? 'yes' : 'no'}</b><span>Origin report</span></div>
965
- <div class="kpi"><b>${esc(adoptionView.baseline.signal)}</b><span>Baseline policy</span></div>
966
- <div class="kpi"><b>${adoptionView.mcp.ok ? 'ok' : 'fix'}</b><span>Repo MCP argv</span></div>
1094
+ ${metricKpi(
1095
+ adoptionView.gaps.length === 0 ? 'OK' : adoptionView.gaps.length,
1096
+ adoptionView.gaps.length === 0 ? 'No adoption gaps' : 'Adoption gap(s)',
1097
+ adoptionView.gaps.length === 0
1098
+ ? 'Hosts, MCP argv, origin snapshot, and core optionality look complete for co-pilot use.'
1099
+ : 'Install or fix the listed gaps so agents get write gates, MCP, and honest cores.'
1100
+ )}
1101
+ ${metricKpi(
1102
+ adoptionView.originReport.present ? 'yes' : 'no',
1103
+ 'Origin report',
1104
+ 'First architecture snapshot under .ark/reports/origin.* — future reports show evolution deltas against it.'
1105
+ )}
1106
+ ${metricKpi(
1107
+ adoptionView.baseline.signal,
1108
+ 'Baseline policy',
1109
+ baselineSignalHint(adoptionView.baseline.signal)
1110
+ )}
1111
+ ${metricKpi(
1112
+ adoptionView.mcp.ok ? 'ok' : 'fix',
1113
+ 'Repo MCP argv',
1114
+ adoptionView.mcp.ok
1115
+ ? 'Repo MCP config points at a single ark/arkgate MCP bin (no dual-bin conflict).'
1116
+ : 'Broken MCP argv: more than one of ark-mcp/arkgate-mcp — migrate with --install-agent-gates --migrate-commands.'
1117
+ )}
967
1118
  </div>
968
1119
  ${
969
1120
  adoptionView.gaps.length
@@ -991,6 +1142,8 @@ export function renderHtmlReport({
991
1142
  .join(' · ')}</p>`
992
1143
  : ''
993
1144
  }
1145
+ ${writePathHtml}
1146
+ ${baselineLegendHtml}
994
1147
  </div>
995
1148
 
996
1149
  <div class="section grid-2">
@@ -1138,10 +1291,26 @@ export function renderHtmlReport({
1138
1291
 
1139
1292
  <h3>Contract density</h3>
1140
1293
  <div class="kpis" style="margin-top:.35rem">
1141
- <div class="kpi"><b>${denyRatio}%</b><span>Edges denied</span></div>
1142
- <div class="kpi"><b>${deniedCount}</b><span>Deny rules</span></div>
1143
- <div class="kpi"><b>${allowedCount}</b><span>Explicit allows</span></div>
1144
- <div class="kpi"><b>${pairCount}</b><span>Directed pairs</span></div>
1294
+ ${metricKpi(
1295
+ `${denyRatio}%`,
1296
+ 'Edges denied',
1297
+ 'Denied directed layer pairs ÷ all possible pairs. Higher = stricter inward dependency rules.'
1298
+ )}
1299
+ ${metricKpi(
1300
+ deniedCount,
1301
+ 'Deny rules',
1302
+ 'Explicit allowed:false rules in ark.config.json (row may not import column).'
1303
+ )}
1304
+ ${metricKpi(
1305
+ allowedCount,
1306
+ 'Explicit allows',
1307
+ 'Explicit allowed:true edges. Most opens are implicit (no rule) unless you document them.'
1308
+ )}
1309
+ ${metricKpi(
1310
+ pairCount,
1311
+ 'Directed pairs',
1312
+ 'layers × (layers − 1) — every ordered from→to pair the matrix can constrain.'
1313
+ )}
1145
1314
  </div>
1146
1315
  <p class="dim" style="margin:.55rem 0 0;font-size:.84rem">
1147
1316
  Deny ratio = denied ÷ (layers × (layers−1)). High ratio = strict inward architecture.
@@ -1270,10 +1439,26 @@ export function renderHtmlReport({
1270
1439
 
1271
1440
  <h3>Debt &amp; violation taxonomy</h3>
1272
1441
  <div class="kpis" style="margin-top:.35rem">
1273
- <div class="kpi"><b>${violations.length}</b><span>Active</span></div>
1274
- <div class="kpi"><b>${valueN}</b><span>Value edges</span></div>
1275
- <div class="kpi"><b>${typeOnlyN}</b><span>Type-only</span></div>
1276
- <div class="kpi"><b>${suppressed || baselineKeys}</b><span>Baseline keys</span></div>
1442
+ ${metricKpi(
1443
+ violations.length,
1444
+ 'Active',
1445
+ 'Violations that fail the check right now (not frozen by baseline).'
1446
+ )}
1447
+ ${metricKpi(
1448
+ valueN,
1449
+ 'Value edges',
1450
+ 'Runtime import edges that cross a deny rule (stronger debt than type-only).'
1451
+ )}
1452
+ ${metricKpi(
1453
+ typeOnlyN,
1454
+ 'Type-only',
1455
+ 'Type-only imports across a deny edge — often mechanical-safe to rewrite as import type.'
1456
+ )}
1457
+ ${metricKpi(
1458
+ suppressed || baselineKeys,
1459
+ 'Baseline keys',
1460
+ 'Distinct frozen debt keys in .ark-baseline.json (or suppressed count for this run).'
1461
+ )}
1277
1462
  </div>
1278
1463
  ${
1279
1464
  topEdges.length
@@ -1291,6 +1476,14 @@ export function renderHtmlReport({
1291
1476
  Coverage=${scoreCoverage}, clean=${scoreClean}, gates=${scoreGates}, rules=${scoreRules} → <b>${score}</b>.
1292
1477
  This is a fitness signal for humans, not a CI gate.
1293
1478
  </p>
1479
+ <ul class="senior-list" style="margin-top:.45rem">
1480
+ <li><b>Coverage</b> — % of in-scope files that match a layer pattern.</li>
1481
+ <li><b>Clean</b> — 100 with zero active violations; falls as breaks accumulate.</li>
1482
+ <li><b>Gates</b> — share of write / CI / ESLint / baseline enforcement points present.</li>
1483
+ <li><b>Rules</b> — deny-matrix density (more inward denies → higher component).</li>
1484
+ <li><b>PASS / FAIL</b> — binary edge honesty (active violations), independent of the 0–100 score.</li>
1485
+ <li><b>SUGGEST / ADAPT / ENFORCE</b> — whether the contract is honest enough to protect the tree (not a skill grade).</li>
1486
+ </ul>
1294
1487
  </details>
1295
1488
  </div>
1296
1489
 
@@ -12,6 +12,7 @@ import {
12
12
  } from '../ark-shared.mjs';
13
13
  import {
14
14
  codexPromptsDir,
15
+ codexSkillsDir,
15
16
  codexConfigPath,
16
17
  isTempOrUpgradeRoot,
17
18
  usesDefaultCodexHome,
@@ -52,6 +53,7 @@ import {
52
53
  isVersionOlder,
53
54
  detectSkillGaps,
54
55
  arkPackageVersion,
56
+ verifyHostSkillCatalog,
55
57
  } from './skill-install.mjs';
56
58
  import { detectDeployPathQuality } from './deploy-path.mjs';
57
59
  import {
@@ -383,15 +385,15 @@ export function runInstallAgentGates(args) {
383
385
  console.log(` ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --force')}`);
384
386
  }
385
387
 
386
- // --codex-home writes the canonical skills straight to $CODEX_HOME/prompts.
387
- // Codex reads prompts from there (not the repo), so this is the only way to
388
- // refresh them for a repo that isn't itself configured for Codex. It writes to
389
- // the user's home dir, hence explicit opt-in rather than part of a normal run.
388
+ // --codex-home writes SKILL.md skills to $CODEX_HOME/skills/<name>/SKILL.md.
389
+ // Codex's real catalog loads skill directories (not flat $CODEX_HOME/prompts).
390
+ // Repo installs already write `.agents/skills/<name>/SKILL.md` when `codex` is
391
+ // selected; home install is for multi-project / non-repo-local refresh.
390
392
  const homeResults = [];
391
393
  if (args.codexHome) {
392
- const dir = codexPromptsDir();
394
+ const dir = codexSkillsDir();
393
395
  console.log('');
394
- console.log(`Codex home skills (${dir}):`);
396
+ console.log(`Codex home skills (${dir}/<name>/SKILL.md):`);
395
397
  try {
396
398
  fs.mkdirSync(dir, { recursive: true });
397
399
  } catch (error) {
@@ -400,23 +402,25 @@ export function runInstallAgentGates(args) {
400
402
  }
401
403
  if (homeResults.length === 0) {
402
404
  for (const [name, content] of skills) {
403
- const file = path.join(dir, `${name}.md`);
405
+ const skillDir = path.join(dir, name);
406
+ const file = path.join(skillDir, 'SKILL.md');
404
407
  if (fs.existsSync(file) && !args.force) {
405
408
  const installed = installedSkillVersion(file);
406
409
  const behind = installed === null || (version && isVersionOlder(installed, version));
407
410
  const note = behind
408
411
  ? ` (stale: ${installed ?? 'no stamp'} < ${version}; use --force)`
409
412
  : ' (up to date)';
410
- console.log(` ${'skipped'.padEnd(7)} ${name}.md${note}`);
413
+ console.log(` ${'skipped'.padEnd(7)} ${name}/SKILL.md${note}`);
411
414
  homeResults.push({ status: 'skipped' });
412
415
  continue;
413
416
  }
414
417
  try {
418
+ fs.mkdirSync(skillDir, { recursive: true });
415
419
  fs.writeFileSync(file, content);
416
- console.log(` ${'wrote'.padEnd(7)} ${name}.md`);
420
+ console.log(` ${'wrote'.padEnd(7)} ${name}/SKILL.md`);
417
421
  homeResults.push({ status: 'written' });
418
422
  } catch (error) {
419
- console.log(` ${'FAILED'.padEnd(7)} ${name}.md (${error.message})`);
423
+ console.log(` ${'FAILED'.padEnd(7)} ${name}/SKILL.md (${error.message})`);
420
424
  homeResults.push({ status: 'failed' });
421
425
  }
422
426
  }
@@ -429,18 +433,15 @@ export function runInstallAgentGates(args) {
429
433
  // home-dir merge instead. Fires whenever Codex is in play so `ark://manifest` is live
430
434
  // without a manual copy step.
431
435
  //
432
- // Skip home-dir mutation when the project root is a temp/upgrade scratch *and*
433
- // CODEX_HOME resolves to the default (~/.codex). Codex itself may export that exact
434
- // path, so presence alone does not prove isolation. Fixtures must not rewrite the
435
- // developer's real config. A genuinely redirected CODEX_HOME or explicit
436
- // --codex-home still wires as requested.
436
+ // Skip home MCP mutation when the project root is a temp/upgrade scratch *and*
437
+ // CODEX_HOME is the default (~/.codex). Fixtures and agent smokes must not rewrite
438
+ // the developer's real config.toml with a temp --root. --codex-home still refreshes
439
+ // home *skills* below; MCP binding of a temp root into default home is never safe.
440
+ // A redirected CODEX_HOME (tests/isolation) may still wire as requested.
437
441
  let codexMcp = null;
438
442
  const wantCodexWire = !args.compact && (tools.has('codex') || args.codexHome);
439
443
  const skipHomeWire =
440
- wantCodexWire &&
441
- isTempOrUpgradeRoot(root) &&
442
- !args.codexHome &&
443
- usesDefaultCodexHome();
444
+ wantCodexWire && isTempOrUpgradeRoot(root) && usesDefaultCodexHome();
444
445
  if (wantCodexWire && !skipHomeWire) {
445
446
  codexMcp = wireCodexMcp(root, args.force);
446
447
  console.log('');
@@ -498,13 +499,68 @@ export function runInstallAgentGates(args) {
498
499
  if (codexMcp && codexMcp.status !== 'failed') {
499
500
  console.log(` Codex: ark MCP registered in ${codexMcp.file} — restart Codex so \`ark://manifest\` loads.`);
500
501
  }
502
+ if (tools.has('codex') && !args.compact) {
503
+ console.log(' Codex write path (honest):');
504
+ console.log(' - Local: advisory MCP + best-effort .codex/hooks.json (not a hard boundary).');
505
+ console.log(' - Hard merge backstop: CI --strict-merge + required status check.');
506
+ console.log(' - Not equivalent to Claude/Grok PreToolUse hard-write + repair.');
507
+ }
501
508
  if (args.codexHome) {
502
- console.log(` Codex: refreshed the /ark-* skills in ${codexPromptsDir()} — Codex loads them from there.`);
503
- } else if (skills.length > 0) {
504
- console.log(' Codex loads slash-command prompts from $CODEX_HOME/prompts (~/.codex/prompts),');
505
- console.log(' not the repo. Install the /ark-* skills there with:');
506
- console.log(` ${arkCommand(root, 'ark-check', '--install-agent-gates --codex-home')}`);
507
- console.log(' (writes to your home dir; agents driving this setup should offer to run it).');
509
+ console.log(
510
+ ` Codex: refreshed home skills under ${codexSkillsDir()}/<name>/SKILL.md (Codex skill catalog).`
511
+ );
512
+ } else if (tools.has('codex') && skills.length > 0 && !args.compact) {
513
+ console.log(
514
+ ' Codex: project skills at `.agents/skills/<name>/SKILL.md` (Agent Skills REPO scope).'
515
+ );
516
+ console.log(
517
+ ` Optional multi-project home copy: ${arkCommand(root, 'ark-check', '--install-agent-gates --codex-home')}`
518
+ );
519
+ console.log(
520
+ ` (writes $CODEX_HOME/skills; legacy flat prompts at ${codexPromptsDir()} are not the skill catalog).`
521
+ );
522
+ // Best-effort note when dead prompts still sit under the repo.
523
+ try {
524
+ const promptDir = path.join(root, '.codex', 'prompts');
525
+ if (fs.existsSync(promptDir)) {
526
+ const legacy = fs.readdirSync(promptDir).filter((n) => /^ark-[a-z0-9-]+\.md$/.test(n));
527
+ if (legacy.length > 0) {
528
+ console.log(
529
+ ` Note: ignoring ${legacy.length} legacy .codex/prompts/ark-*.md file(s) — not loadable as Codex skills.`
530
+ );
531
+ }
532
+ }
533
+ } catch {
534
+ // ignore
535
+ }
536
+ }
537
+ }
538
+
539
+ // Post-install: AGENTS.md /ark-* refs must exist in each selected host catalog.
540
+ // Compact routers intentionally omit those refs (package/MCP is the router).
541
+ if (!args.compact && skills.length > 0) {
542
+ const catalog = verifyHostSkillCatalog(root, tools, {
543
+ skillNames: skills.map(([name]) => name),
544
+ });
545
+ if (!catalog.ok) {
546
+ console.log('');
547
+ console.error(
548
+ `Skill catalog verification failed: ${catalog.missing.length} AGENTS.md /ark-* reference(s) missing from host catalogs.`
549
+ );
550
+ for (const miss of catalog.missing.slice(0, 12)) {
551
+ console.error(` missing ${miss.tool}: ${miss.path}`);
552
+ }
553
+ if (catalog.missing.length > 12) {
554
+ console.error(` …and ${catalog.missing.length - 12} more`);
555
+ }
556
+ process.exitCode = 1;
557
+ return;
558
+ }
559
+ if (catalog.checkedTools.length > 0 && catalog.referenced.length > 0) {
560
+ console.log('');
561
+ console.log(
562
+ `Skill catalog verified: ${catalog.referenced.length} AGENTS.md /ark-* skill(s) present for ${catalog.checkedTools.join(', ')}.`
563
+ );
508
564
  }
509
565
  }
510
566
  warnLockfileConflict(root);
@@ -143,6 +143,14 @@ export function collectAdoptionGaps(root, config, coverage) {
143
143
  extras: [['.cursor/mcp.json', 'MCP config']],
144
144
  toolsFlag: 'cursor',
145
145
  },
146
+ {
147
+ host: 'codex',
148
+ dir: '.codex',
149
+ // Official Codex REPO skill catalog (Agent Skills standard) — not .codex/prompts.
150
+ skill: (n) => path.join(root, '.agents', 'skills', n, 'SKILL.md'),
151
+ extras: [['.codex/hooks.json', 'hooks']],
152
+ toolsFlag: 'codex',
153
+ },
146
154
  ];
147
155
  for (const h of hostChecks) {
148
156
  if (!fs.existsSync(path.join(root, h.dir))) continue;