arkgate 4.1.0 → 4.2.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 (63) hide show
  1. package/CHANGELOG.md +124 -2
  2. package/README.md +24 -14
  3. package/bin/ark-check-runtime.mjs +16 -5
  4. package/bin/ark-mcp-runtime.mjs +766 -64
  5. package/bin/lib/agent-gates.mjs +1 -0
  6. package/bin/lib/ark-gitignore.mjs +88 -0
  7. package/bin/lib/ci-and-commands.mjs +33 -8
  8. package/bin/lib/codex-home.mjs +90 -8
  9. package/bin/lib/design-smells.mjs +71 -9
  10. package/bin/lib/doctor-plan.mjs +47 -39
  11. package/bin/lib/effective-contract-load.mjs +73 -9
  12. package/bin/lib/enforcement-honesty.mjs +78 -22
  13. package/bin/lib/enforcement-state.mjs +1 -1
  14. package/bin/lib/gate-files.mjs +441 -9
  15. package/bin/lib/github-enforcement.mjs +168 -7
  16. package/bin/lib/hook-templates.mjs +12 -11
  17. package/bin/lib/host-support-matrix.mjs +91 -17
  18. package/bin/lib/html-report-depth.mjs +13 -2
  19. package/bin/lib/html-report-evolution.mjs +114 -0
  20. package/bin/lib/html-report.mjs +18 -97
  21. package/bin/lib/import-resolve.mjs +33 -11
  22. package/bin/lib/install-activation.mjs +87 -0
  23. package/bin/lib/install-migrate.mjs +66 -50
  24. package/bin/lib/managed-upgrade.mjs +10 -41
  25. package/bin/lib/mcp-adoption.mjs +15 -5
  26. package/bin/lib/pilot-loop.mjs +25 -8
  27. package/bin/lib/project-identity.mjs +103 -0
  28. package/bin/lib/report-snapshot-context.mjs +28 -0
  29. package/bin/lib/resident-hook.mjs +33 -9
  30. package/bin/lib/rules-inventory.mjs +100 -8
  31. package/bin/lib/skill-install.mjs +272 -22
  32. package/bin/lib/skill-write.mjs +899 -0
  33. package/bin/lib/start-preview.mjs +84 -1
  34. package/bin/lib/upgrade-command.mjs +2 -5
  35. package/bin/lib/write-path-detect.mjs +2 -2
  36. package/dist/index.cjs +13 -13
  37. package/dist/index.d.ts +194 -2
  38. package/dist/index.js +13 -13
  39. package/docs/README.md +6 -4
  40. package/docs/agent-guide.md +115 -17
  41. package/docs/ai-gates.md +133 -25
  42. package/docs/assets/ark-write-gate.svg +2 -2
  43. package/docs/develop.md +16 -6
  44. package/docs/enthusiast/how-to-agent-gates.md +6 -0
  45. package/docs/package-surface.md +16 -9
  46. package/docs/product-voice.md +22 -4
  47. package/docs/use.md +3 -1
  48. package/package.json +3 -1
  49. package/schemas/ark.project-identity.schema.json +116 -0
  50. package/server.json +2 -2
  51. package/templates/skills/ark-adopt.md +9 -0
  52. package/templates/skills/ark-architect.md +12 -2
  53. package/templates/skills/ark-autopilot.md +9 -0
  54. package/templates/skills/ark-contract.md +11 -1
  55. package/templates/skills/ark-coverage.md +9 -0
  56. package/templates/skills/ark-explain.md +13 -1
  57. package/templates/skills/ark-explore.md +9 -0
  58. package/templates/skills/ark-fix.md +10 -1
  59. package/templates/skills/ark-loop.md +11 -2
  60. package/templates/skills/ark-place.md +17 -6
  61. package/templates/skills/ark-runtime.md +8 -0
  62. package/templates/skills/ark-think.md +14 -2
  63. package/templates/skills/ark-upgrade.md +9 -0
@@ -21,6 +21,11 @@ import {
21
21
  } from './html-report-depth.mjs';
22
22
  import { FIX_HINTS } from './violations.mjs';
23
23
  import { capabilityBadgesFor, renderAdvisorySections } from './html-report-advisories.mjs';
24
+ import { renderEvolutionSection } from './html-report-evolution.mjs';
25
+ import { arkGitignoreAppendDecision } from './ark-gitignore.mjs';
26
+ import { captureGitSnapshot } from './report-snapshot-context.mjs';
27
+
28
+ export { arkGitignoreAppendDecision, gitignoreCoversArkState, gitignoreHasArkNegationException } from './ark-gitignore.mjs';
24
29
 
25
30
  export function detectEnforcement(root) {
26
31
  const has = (rel) => fs.existsSync(path.join(root, rel));
@@ -158,6 +163,7 @@ export function buildReportSnapshot({
158
163
  return path.basename(root);
159
164
  }
160
165
  })(),
166
+ git: captureGitSnapshot(root),
161
167
  ok: Boolean(ok),
162
168
  mode: mode ?? null,
163
169
  score: score ?? null,
@@ -336,20 +342,16 @@ export function archiveReportSnapshots(root, { html, snapshot, resetOrigin = fal
336
342
  }
337
343
  }
338
344
 
339
- // Ensure .ark/ is gitignored when a .gitignore exists.
345
+ // EH03: cover .ark reports in .gitignore without defeating ! exceptions.
340
346
  const gitignore = path.join(root, '.gitignore');
341
347
  if (fs.existsSync(gitignore)) {
342
348
  const text = fs.readFileSync(gitignore, 'utf8');
343
- const hasArk =
344
- text.split('\n').some((line) => {
345
- const t = line.trim();
346
- return t === '.ark/' || t === '.ark' || t === '/.ark/' || t === '**/.ark/';
347
- });
348
- if (!hasArk) {
349
+ const decision = arkGitignoreAppendDecision(text);
350
+ if (decision.append && decision.rule) {
349
351
  const suffix = text.endsWith('\n') || text.length === 0 ? '' : '\n';
350
352
  fs.writeFileSync(
351
353
  gitignore,
352
- `${text}${suffix}\n# Ark generated reports / local state\n.ark/\n`
354
+ `${text}${suffix}\n# Ark generated reports / local state\n${decision.rule}\n`
353
355
  );
354
356
  }
355
357
  }
@@ -1229,95 +1231,14 @@ export function renderHtmlReport({
1229
1231
  <div class="gates">${enforcementRows}</div>
1230
1232
  </div>
1231
1233
 
1232
- ${(() => {
1233
- if (!currentSnapshot) return '';
1234
- // First report: originSnapshot is null at render time (written to disk just after).
1235
- if (originJustCreated || !originSnapshot) {
1236
- return `<div class="section card evolve">
1237
- <h2>Origin baseline captured</h2>
1238
- <p class="dim" style="margin:.2rem 0 0;font-size:.9rem">
1239
- This is the <b>first</b> architecture snapshot for this project
1240
- (<code>.ark/reports/origin.json</code> + <code>origin.html</code>).
1241
- Future reports will show deltas against this starting point so you can prove evolution.
1242
- </p>
1243
- </div>`;
1244
- }
1245
- const rows = [
1246
- ['Ark score', originSnapshot.score, currentSnapshot.score, ''],
1247
- ['Governed %', originSnapshot.governedPercent, currentSnapshot.governedPercent, 'pp'],
1248
- ['Files in scope', originSnapshot.totalFiles, currentSnapshot.totalFiles, ''],
1249
- ['Classified files', originSnapshot.classifiedFiles, currentSnapshot.classifiedFiles, ''],
1250
- ['Active violations', originSnapshot.activeViolations, currentSnapshot.activeViolations, ''],
1251
- ['Value violations', originSnapshot.valueViolations, currentSnapshot.valueViolations, ''],
1252
- ['Type-only violations', originSnapshot.typeOnlyViolations, currentSnapshot.typeOnlyViolations, ''],
1253
- ['Layers', originSnapshot.layerCount, currentSnapshot.layerCount, ''],
1254
- ['Deny rules', originSnapshot.denyRules, currentSnapshot.denyRules, ''],
1255
- ['Gates configured', originSnapshot.gatesOn, currentSnapshot.gatesOn, ''],
1256
- ];
1257
- const originDate = (originSnapshot.generatedAt || '').slice(0, 10) || 'origin';
1258
- const nowDate = (currentSnapshot.generatedAt || '').slice(0, 10) || 'now';
1259
- const tr = rows
1260
- .map(([label, from, to, unit]) => {
1261
- const d =
1262
- typeof from === 'number' && typeof to === 'number' ? to - from : null;
1263
- const good =
1264
- label.includes('violation') || label.includes('Violation')
1265
- ? d != null && d <= 0
1266
- : label.includes('Governed') || label.includes('score') || label.includes('Classified') || label.includes('Gates')
1267
- ? d != null && d >= 0
1268
- : null;
1269
- const cls =
1270
- d == null || d === 0 ? 'flat' : good === true ? 'up' : good === false ? 'down' : 'flat';
1271
- const delta =
1272
- d == null
1273
- ? '—'
1274
- : unit === 'pp'
1275
- ? formatDelta(Math.round(d * 10) / 10, { suffix: ' pp' })
1276
- : formatDelta(d);
1277
- return `<tr>
1278
- <td>${esc(label)}</td>
1279
- <td class="num">${from ?? '—'}</td>
1280
- <td class="num">${to ?? '—'}</td>
1281
- <td class="num delta ${cls}">${esc(delta)}</td>
1282
- </tr>`;
1283
- })
1284
- .join('\n');
1285
- // Layer file deltas
1286
- const originLayers = originSnapshot.layerFiles || {};
1287
- const currentLayers = currentSnapshot.layerFiles || {};
1288
- const layerKeys = [...new Set([...Object.keys(originLayers), ...Object.keys(currentLayers)])].sort();
1289
- const layerTr = layerKeys
1290
- .map((name) => {
1291
- const from = originLayers[name] || 0;
1292
- const to = currentLayers[name] || 0;
1293
- const d = to - from;
1294
- const cls = d === 0 ? 'flat' : d > 0 ? 'up' : 'down';
1295
- return `<tr>
1296
- <td class="ln">${esc(name)}</td>
1297
- <td class="num">${from}</td>
1298
- <td class="num">${to}</td>
1299
- <td class="num delta ${cls}">${esc(formatDelta(d))}</td>
1300
- </tr>`;
1301
- })
1302
- .join('\n');
1303
- return `<div class="section card evolve">
1304
- <h2>Evolution vs origin</h2>
1305
- <p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">
1306
- Origin snapshot <code>${esc(originDate)}</code> → this report <code>${esc(nowDate)}</code>
1307
- · frozen at <code>.ark/reports/origin.*</code> · reopen origin HTML anytime for the starting picture.
1308
- </p>
1309
- <table class="layers">
1310
- <tr><th>Metric</th><th>Origin</th><th>Now</th><th>Δ</th></tr>
1311
- ${tr}
1312
- </table>
1313
- <h3>Files per layer</h3>
1314
- <table class="layers">
1315
- <tr><th>Layer</th><th>Origin</th><th>Now</th><th>Δ</th></tr>
1316
- ${layerTr || '<tr><td colspan="4" class="dim">No layer file data in snapshots.</td></tr>'}
1317
- </table>
1318
- <p class="legend">Green Δ = improvement for that metric (↑ coverage/score/gates, ↓ violations). History JSON under <code>.ark/reports/history/</code> (last ${ARK_REPORT_HISTORY_MAX}).</p>
1319
- </div>`;
1320
- })()}
1234
+ ${renderEvolutionSection({
1235
+ originSnapshot,
1236
+ currentSnapshot,
1237
+ originJustCreated,
1238
+ esc,
1239
+ formatDelta,
1240
+ historyMax: ARK_REPORT_HISTORY_MAX,
1241
+ })}
1321
1242
 
1322
1243
  <div class="section card senior">
1323
1244
  <h2>Senior diagnostics</h2>
@@ -35,32 +35,55 @@ export function readTsconfigAliases(ts, root) {
35
35
  }
36
36
  }
37
37
 
38
+ function canonicalPathWithMissingTail(value) {
39
+ const resolved = path.resolve(value);
40
+ let current = resolved;
41
+ const tail = [];
42
+ while (true) {
43
+ try {
44
+ return path.join(fs.realpathSync(current), ...tail.reverse());
45
+ } catch {
46
+ const parent = path.dirname(current);
47
+ if (parent === current) return resolved;
48
+ tail.push(path.basename(current));
49
+ current = parent;
50
+ }
51
+ }
52
+ }
53
+
38
54
  /**
39
55
  * Resolve an import specifier to a repo-relative path.
40
56
  * Relative + tsconfig-aliased only; bare packages → undefined.
41
57
  */
42
58
  export function resolveSpecifierToRel(specifier, fromFilePath, root, tsAliases) {
59
+ const canonicalRoot = canonicalPathWithMissingTail(root);
43
60
  let abs;
44
61
  if (specifier.startsWith('./') || specifier.startsWith('../')) {
45
62
  if (!fromFilePath) return undefined;
46
- const fromAbs = path.isAbsolute(fromFilePath)
47
- ? fromFilePath
48
- : path.resolve(root, fromFilePath);
49
- abs = path.resolve(path.dirname(fromAbs), specifier);
63
+ const fromAbs = canonicalPathWithMissingTail(
64
+ path.isAbsolute(fromFilePath) ? fromFilePath : path.resolve(root, fromFilePath)
65
+ );
66
+ abs = canonicalPathWithMissingTail(path.resolve(path.dirname(fromAbs), specifier));
50
67
  } else {
51
68
  const alias = tsAliases.aliases.find((a) => specifier.startsWith(a.from));
52
69
  if (!alias) return undefined;
53
- abs = path.resolve(tsAliases.baseUrl, `${alias.to}${specifier.slice(alias.from.length)}`);
70
+ abs = canonicalPathWithMissingTail(
71
+ path.resolve(tsAliases.baseUrl, `${alias.to}${specifier.slice(alias.from.length)}`)
72
+ );
54
73
  }
55
- const rel = path.relative(root, abs).split(path.sep).join('/');
56
- return rel.startsWith('..') ? undefined : rel;
74
+ const relative = path.relative(canonicalRoot, abs);
75
+ if (path.isAbsolute(relative) || relative.startsWith('..')) return undefined;
76
+ return relative.split(path.sep).join('/');
57
77
  }
58
78
 
59
79
  function filePathToRel(filePath, root) {
60
80
  if (!filePath || typeof filePath !== 'string') return undefined;
61
- const abs = path.isAbsolute(filePath) ? filePath : path.resolve(root, filePath);
62
- const rel = path.relative(root, abs).split(path.sep).join('/');
63
- return rel.startsWith('..') ? undefined : rel;
81
+ const abs = canonicalPathWithMissingTail(
82
+ path.isAbsolute(filePath) ? filePath : path.resolve(root, filePath)
83
+ );
84
+ const relative = path.relative(canonicalPathWithMissingTail(root), abs);
85
+ if (path.isAbsolute(relative) || relative.startsWith('..')) return undefined;
86
+ return relative.split(path.sep).join('/');
64
87
  }
65
88
 
66
89
  function classifyProbe(root, rel, layers) {
@@ -130,4 +153,3 @@ export function createImportTargetResolver(ts, root, config) {
130
153
  return undefined;
131
154
  };
132
155
  }
133
-
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { arkCommand } from '../ark-shared.mjs';
4
+ import { codexProjectMcpIsValid } from './codex-home.mjs';
5
+ import { codexRuntimeActivation } from './enforcement-state.mjs';
6
+
7
+ export function inspectCodexInstallActivation(root, enabled) {
8
+ let configuredOnDisk = false;
9
+ if (enabled) {
10
+ try {
11
+ configuredOnDisk = codexProjectMcpIsValid(
12
+ fs.readFileSync(path.join(root, '.codex', 'config.toml'), 'utf8'),
13
+ root
14
+ );
15
+ } catch {
16
+ // Missing/unreadable configuration remains explicitly unverified.
17
+ }
18
+ }
19
+ return {
20
+ codexProjectConfigured: configuredOnDisk,
21
+ runtimeActivation: codexRuntimeActivation({
22
+ configuredOnDisk,
23
+ restartRequired: configuredOnDisk,
24
+ }),
25
+ };
26
+ }
27
+
28
+ export function reportPartialInstall({
29
+ root,
30
+ tools,
31
+ results,
32
+ homeResults,
33
+ earlyWritten,
34
+ codexMcp,
35
+ runtimeActivation,
36
+ }) {
37
+ const failed = [...results, ...homeResults]
38
+ .filter((result) => result.status === 'failed')
39
+ .map((result) => ({
40
+ target: result.relativePath ?? '(unknown template)',
41
+ reason: 'write failed',
42
+ }));
43
+ if (codexMcp?.status === 'failed') {
44
+ failed.push({
45
+ target: codexMcp.file,
46
+ reason: codexMcp.message ?? 'Codex MCP registration failed',
47
+ });
48
+ }
49
+ if (failed.length === 0) return false;
50
+
51
+ const written = new Set([
52
+ ...earlyWritten,
53
+ ...[...results, ...homeResults]
54
+ .filter((result) => result.status === 'written' || result.status === 'merged')
55
+ .map((result) => result.relativePath),
56
+ ]);
57
+ console.error('\nINSTALL PARTIAL — some artifacts were written; activation is not complete.');
58
+ console.error('Written:');
59
+ if (written.size === 0) console.error(' - (none)');
60
+ for (const relativePath of written) console.error(` - ${relativePath}`);
61
+ console.error('Failed:');
62
+ for (const failure of failed) console.error(` - ${failure.target}: ${failure.reason}`);
63
+ console.error('Recovery:');
64
+ console.error(
65
+ ` - Fix the listed path or permission error, then re-run ${arkCommand(root, 'ark-check', `--install-agent-gates${tools.size > 0 ? ` --tools ${[...tools].join(',')}` : ''}`)}.`
66
+ );
67
+ console.error(' - Existing customized files were preserved; no destructive rollback was attempted.');
68
+ if (tools.has('codex')) {
69
+ console.error(` - Runtime activation: ${JSON.stringify(runtimeActivation)}`);
70
+ }
71
+ return true;
72
+ }
73
+
74
+ export function printCodexActivationHandoff(root, configuredOnDisk, runtimeActivation) {
75
+ console.log(
76
+ configuredOnDisk
77
+ ? ' CODEX MCP CONFIGURED — RUNTIME NOT VERIFIED'
78
+ : ' CODEX MCP CONFIGURATION UNRESOLVED — RUNTIME NOT VERIFIED'
79
+ );
80
+ console.log(` Runtime activation: ${JSON.stringify(runtimeActivation)}`);
81
+ console.log(
82
+ configuredOnDisk
83
+ ? ` Restart Codex, then call ark_identity with expectedRoot "${path.resolve(root)}".`
84
+ : ' Repair `.codex/config.toml`, then restart Codex and call ark_identity.'
85
+ );
86
+ console.log(' Do not trust MCP verdicts before the project identity matches.');
87
+ }
@@ -63,12 +63,11 @@ import {
63
63
  SKILL_TOOL_TARGETS,
64
64
  skillTemplates,
65
65
  stampSkill,
66
- installedSkillVersion,
67
- isVersionOlder,
68
66
  detectSkillGaps,
69
67
  arkPackageVersion,
70
68
  verifyHostSkillCatalog,
71
69
  } from './skill-install.mjs';
70
+ import { installRepoSkillFile, installSkillCatalog, skillInstallLine, skillInstallNote } from './skill-write.mjs';
72
71
  import { detectDeployPathQuality } from './deploy-path.mjs';
73
72
  import {
74
73
  stripMcpServerArgs,
@@ -78,6 +77,7 @@ import {
78
77
  PREFERRED_CHECK_BIN,
79
78
  RUNNER_BEFORE_ARK,
80
79
  } from './mcp-adoption.mjs';
80
+ import { inspectCodexInstallActivation, printCodexActivationHandoff, reportPartialInstall } from './install-activation.mjs';
81
81
  import {
82
82
  hasHardWriteHook,
83
83
  validateHardWriteRequest,
@@ -181,7 +181,7 @@ export function buildManagedAssetCatalog({ root, tools, compact = false, skillsO
181
181
  compact ? compactAgentInstructions(root, compactHost) : agentInstructions(root)
182
182
  );
183
183
  // Always write project MCP registration — compact hosts other than Claude still need
184
- // ark://manifest for agents (field: compact grok start left doctor reporting Missing .mcp.json
184
+ // project-bound ark_identity/ark_manifest (field: compact Grok start left doctor reporting Missing .mcp.json
185
185
  // when AGENTS lost compact markers or hosts were mixed).
186
186
  add('.mcp.json', mcpJson(root));
187
187
  const deploy = detectDeployPathQuality(root);
@@ -416,9 +416,11 @@ export function runInstallAgentGates(args) {
416
416
  // settings.json, CI workflow, rules) are the ones users customize, so a plain
417
417
  // `--force` clobbers them — this is the safe way to pick up new skill versions.
418
418
  // Do not mutate package.json under --skills-only (typecheck bootstrap is gates/CI).
419
+ const earlyWritten = new Set();
419
420
  if (!args.skillsOnly) {
420
421
  // S4.4: always write check:architecture on gate install / start (including compact).
421
422
  const checkBootstrap = ensureCheckArchitectureScript(root, { write: true });
423
+ if (checkBootstrap.changed) earlyWritten.add('package.json');
422
424
  if (checkBootstrap.changed && !args.json) {
423
425
  console.log(
424
426
  `Added package.json script "check:architecture": "${checkBootstrap.script}" (local/CI parity).`
@@ -426,6 +428,7 @@ export function runInstallAgentGates(args) {
426
428
  }
427
429
  // Bootstrap typecheck before CI template so generated workflow includes the step.
428
430
  const typecheckBootstrap = ensureTypecheckScript(root, { write: !args.compact });
431
+ if (typecheckBootstrap.changed && !args.compact) earlyWritten.add('package.json');
429
432
  if (typecheckBootstrap.changed && !args.compact && !args.json) {
430
433
  console.log(
431
434
  `Added package.json script "typecheck": "${typecheckBootstrap.script}" (tsconfig present; local/CI parity).`
@@ -438,7 +441,7 @@ export function runInstallAgentGates(args) {
438
441
  compact: args.compact,
439
442
  skillsOnly: args.skillsOnly,
440
443
  });
441
- const { skills, skillPaths, version } = catalog;
444
+ const { skills, version } = catalog;
442
445
  const assetKindByPath = new Map(
443
446
  catalog.assets.map((asset) => [asset.relativePath, asset.kind ?? 'gate'])
444
447
  );
@@ -450,7 +453,10 @@ export function runInstallAgentGates(args) {
450
453
  if (args.compact && priorCompactHost === 'none' && [...tools][0]) {
451
454
  const genericMcp = path.join(root, '.mcp.json');
452
455
  try {
453
- if (fs.readFileSync(genericMcp, 'utf8') === mcpJson(root)) fs.rmSync(genericMcp);
456
+ if (fs.readFileSync(genericMcp, 'utf8') === mcpJson(root)) {
457
+ fs.rmSync(genericMcp);
458
+ earlyWritten.add('.mcp.json');
459
+ }
454
460
  } catch {
455
461
  // Missing or customized user MCP configuration is deliberately retained.
456
462
  }
@@ -459,6 +465,9 @@ export function runInstallAgentGates(args) {
459
465
  const results = templates.map(([relativePath, content]) => {
460
466
  // S4.1: preserve content-identity customized/conflicted managed assets even under --force.
461
467
  const kind = assetKindByPath.get(relativePath) ?? 'gate';
468
+ if (kind === 'skill') {
469
+ return installRepoSkillFile(root, relativePath, content, version, args.force);
470
+ }
462
471
  if (
463
472
  args.force &&
464
473
  managedPresent &&
@@ -477,7 +486,14 @@ export function runInstallAgentGates(args) {
477
486
  }
478
487
  const tableStart = content.indexOf('[mcp_servers.ark]');
479
488
  const generatedPrelude = tableStart > 0 ? content.slice(0, tableStart) : '';
480
- const mergeBase = generatedPrelude ? existing.replace(generatedPrelude, '') : existing;
489
+ const mergeBase = generatedPrelude
490
+ ? existing
491
+ .replace(generatedPrelude, '')
492
+ .replace(
493
+ /^# Generated by ark-check --install-agent-gates \(Codex project scope\)\.\n# Restart Codex after changes; MCP servers are loaded when the project session starts\.\n/m,
494
+ ''
495
+ )
496
+ : existing;
481
497
  const merged = upsertCodexMcpTable(mergeBase, 'ark', content);
482
498
  if (merged === existing) return { relativePath, status: 'skipped' };
483
499
  return writeTemplate(root, relativePath, merged, true);
@@ -527,7 +543,9 @@ export function runInstallAgentGates(args) {
527
543
  );
528
544
  });
529
545
 
530
- console.log('Ark agent gate templates:');
546
+ console.log(
547
+ `Ark agent gate templates (scope=repo; source=${version ? `arkgate@${version}` : 'arkgate@unknown'}):`
548
+ );
531
549
  let staleSkipped = 0;
532
550
  let preservedCustomized = 0;
533
551
  for (const result of results) {
@@ -549,13 +567,10 @@ export function runInstallAgentGates(args) {
549
567
  if (result.status === 'skipped-customized') {
550
568
  preservedCustomized += 1;
551
569
  note = ' (customized — content-identity preserved)';
552
- } else if (result.status === 'skipped' && skillPaths.has(result.relativePath) && version) {
553
- const installed = installedSkillVersion(path.join(root, result.relativePath));
554
- if (installed === null || isVersionOlder(installed, version)) {
570
+ } else if (result.status === 'skipped' && result.skillPlan) {
571
+ note = ` (${skillInstallNote(result.skillPlan)})`;
572
+ if (result.skillPlan.reason === 'existing-preserved') {
555
573
  staleSkipped += 1;
556
- note = ` (stale: ${installed ?? 'no stamp'} < ${version})`;
557
- } else {
558
- note = ' (up to date)';
559
574
  }
560
575
  }
561
576
  console.log(` ${marker.padEnd(7)} ${result.relativePath}${note}`);
@@ -581,36 +596,29 @@ export function runInstallAgentGates(args) {
581
596
  if (args.codexHome) {
582
597
  const dir = codexSkillsDir();
583
598
  console.log('');
584
- console.log(`Codex home skills (${dir}/<name>/SKILL.md):`);
599
+ console.log(
600
+ `Codex home skills (scope=home-shared; source=${version ? `arkgate@${version}` : 'arkgate@unknown'}; target=${dir}/<name>/SKILL.md):`
601
+ );
602
+ console.log(
603
+ ' Compatibility: monotonic downgrade protection requires every shared-catalog writer ' +
604
+ 'to use ArkGate 4.2.0+; pre-4.2 --codex-home ignores this catalog. Upgrade legacy repos first.'
605
+ );
585
606
  try {
586
607
  fs.mkdirSync(dir, { recursive: true });
587
608
  } catch (error) {
588
609
  console.error(` FAILED to create ${dir} (${error.message})`);
589
- homeResults.push({ status: 'failed' });
610
+ homeResults.push({ relativePath: dir, status: 'failed' });
590
611
  }
591
612
  if (homeResults.length === 0) {
592
- for (const [name, content] of skills) {
593
- const skillDir = path.join(dir, name);
594
- const file = path.join(skillDir, 'SKILL.md');
595
- if (fs.existsSync(file) && !args.force) {
596
- const installed = installedSkillVersion(file);
597
- const behind = installed === null || (version && isVersionOlder(installed, version));
598
- const note = behind
599
- ? ` (stale: ${installed ?? 'no stamp'} < ${version}; use --force)`
600
- : ' (up to date)';
601
- console.log(` ${'skipped'.padEnd(7)} ${name}/SKILL.md${note}`);
602
- homeResults.push({ status: 'skipped' });
603
- continue;
604
- }
605
- try {
606
- fs.mkdirSync(skillDir, { recursive: true });
607
- fs.writeFileSync(file, content);
608
- console.log(` ${'wrote'.padEnd(7)} ${name}/SKILL.md`);
609
- homeResults.push({ status: 'written' });
610
- } catch (error) {
611
- console.log(` ${'FAILED'.padEnd(7)} ${name}/SKILL.md (${error.message})`);
612
- homeResults.push({ status: 'failed' });
613
- }
613
+ for (const result of installSkillCatalog({
614
+ directory: dir,
615
+ skills,
616
+ packageVersion: version,
617
+ force: args.force,
618
+ scope: 'home',
619
+ })) {
620
+ console.log(skillInstallLine(result));
621
+ homeResults.push(result);
614
622
  }
615
623
  }
616
624
  }
@@ -643,26 +651,28 @@ export function runInstallAgentGates(args) {
643
651
  const verb = codexMcp.status === 'updated' ? 'updated' : 'wrote';
644
652
  console.log(` ${verb.padEnd(7)} [mcp_servers.ark] with absolute paths`);
645
653
  console.log(' RESTART Codex — it does not hot-load MCP servers.');
646
- console.log(' Then expect: resource ark://manifest + tools validate_code, ark_check, ark_coverage, ark_place.');
654
+ console.log(
655
+ ' Then expect: tools ark_identity, ark_manifest, validate_code, ark_check, ark_coverage, ark_place.'
656
+ );
647
657
  }
648
658
  } else if (skipHomeWire) {
649
659
  codexMcp = { status: 'skipped', file: codexConfigPath(), reason: 'temp-root' };
650
660
  }
651
661
 
652
- // Repo templates + explicit --codex-home skill writes are hard failures.
653
- // Home MCP wire is best-effort: unreadable ~/.codex (sandbox, permissions) must not
654
- // mark an otherwise successful repo gate install as failed.
655
- const hardFailed = [...results, ...homeResults].filter((result) => result.status === 'failed');
656
- if (hardFailed.length > 0) {
657
- console.error(`\nFailed to write ${hardFailed.length} template(s).`);
662
+ const { codexProjectConfigured, runtimeActivation } =
663
+ inspectCodexInstallActivation(root, tools.has('codex') && !args.skillsOnly);
664
+ if (reportPartialInstall({
665
+ root,
666
+ tools,
667
+ results,
668
+ homeResults,
669
+ earlyWritten,
670
+ codexMcp,
671
+ runtimeActivation,
672
+ })) {
658
673
  process.exitCode = 1;
659
674
  return;
660
675
  }
661
- if (codexMcp?.status === 'failed') {
662
- console.error(
663
- `\nWarning: Codex home MCP registration failed (${codexMcp.message}). Repo gates were written; fix ~/.codex access or re-run with --codex-home --force.`
664
- );
665
- }
666
676
  if (writeRequest.host) {
667
677
  if (!hasHardWriteHook(root, writeRequest.host)) {
668
678
  console.error(`\nFailed to verify the ${writeRequest.host} hard-write hook after install.`);
@@ -731,8 +741,14 @@ export function runInstallAgentGates(args) {
731
741
  }
732
742
  if ((tools.has('codex') || args.codexHome)) {
733
743
  console.log('');
744
+ if (tools.has('codex') && !args.skillsOnly) {
745
+ printCodexActivationHandoff(root, codexProjectConfigured, runtimeActivation);
746
+ }
734
747
  if (codexMcp && codexMcp.status !== 'failed') {
735
- console.log(` Codex: ark MCP registered in ${codexMcp.file} — restart Codex so \`ark://manifest\` loads.`);
748
+ console.log(
749
+ ` Codex: ark MCP registered in ${codexMcp.file} — restart Codex, then bind with ` +
750
+ '`ark_identity` and read the contract with `ark_manifest`.'
751
+ );
736
752
  }
737
753
  if (tools.has('codex') && !args.compact) {
738
754
  console.log(' Codex write path (honest):');
@@ -371,9 +371,9 @@ function summaryFor(assets, manifestChanged) {
371
371
  const states = {};
372
372
  for (const asset of assets) states[asset.state] = (states[asset.state] ?? 0) + 1;
373
373
  const applying = assets.filter((asset) => asset.willApply);
374
- // Content writes (stale/missing/conflicted accepted) — not version-stamp metadata-only.
375
- const wouldWrite = applying.filter((asset) => asset.action !== 'refresh-metadata').length;
376
- const metadataRefresh = applying.filter((asset) => asset.action === 'refresh-metadata').length;
374
+ const wouldWrite = applying.length;
375
+ // Public-summary compatibility: stamp-only writes are no longer scheduled.
376
+ const metadataRefresh = 0;
377
377
  const customizedPreserved = assets.filter((asset) => asset.state === 'customized').length;
378
378
  const fileChanges = applying.length;
379
379
  return {
@@ -385,7 +385,6 @@ function summaryFor(assets, manifestChanged) {
385
385
  customizedPreserved,
386
386
  fileChanges,
387
387
  manifestChanged,
388
- // Full apply count still includes optional stamp refresh + manifest bookkeeping.
389
388
  changed: fileChanges + (manifestChanged ? 1 : 0),
390
389
  blocked: assets.filter((asset) => asset.blocked).length,
391
390
  };
@@ -447,12 +446,7 @@ export function planManagedUpgrade(root, options = {}) {
447
446
  kind: catalogAsset.kind,
448
447
  });
449
448
  const accepted = options.acceptConflicts === true;
450
- const refreshMetadata =
451
- classified.state === 'current' &&
452
- catalogAsset.kind === 'skill' &&
453
- currentScoped !== desiredScoped;
454
449
  const canApply =
455
- refreshMetadata ||
456
450
  classified.state === 'stale' ||
457
451
  (classified.state === 'missing' && (!recorded || accepted)) ||
458
452
  (classified.state === 'conflicted' && accepted);
@@ -465,7 +459,7 @@ export function planManagedUpgrade(root, options = {}) {
465
459
  scope: catalogAsset.scope,
466
460
  ...classified,
467
461
  ...(unparsedScope ? { reason: 'unparsed managed TOML scope preserved' } : {}),
468
- action: refreshMetadata ? 'refresh-metadata' : canApply ? (currentScoped == null ? 'create' : 'update') : 'none',
462
+ action: canApply ? (currentScoped == null ? 'create' : 'update') : 'none',
469
463
  willApply: canApply,
470
464
  blocked,
471
465
  beforeHash: hash(currentScoped == null ? null : Buffer.from(currentScoped)),
@@ -615,9 +609,7 @@ export function applyManagedUpgrade(root, plan, expectedPlanDigest) {
615
609
  if (resolvedRoot !== plan.root) throw new Error('managed upgrade plan root mismatch');
616
610
  if (plan.summary.blocked > 0) return publicPlan(plan, { blocked: true });
617
611
  const wouldWrite = plan.summary.wouldWrite ?? 0;
618
- const metadataRefresh = plan.summary.metadataRefresh ?? 0;
619
612
  // Content already matches: unbound --apply is a no-op (exit success), not a digest error.
620
- // Optional stamp-only refresh still requires the preview's exact --plan-digest.
621
613
  if (!expectedPlanDigest || expectedPlanDigest !== plan.planDigest) {
622
614
  if (wouldWrite === 0 && (plan.summary.blocked ?? 0) === 0 && !expectedPlanDigest) {
623
615
  return publicPlan(plan, {
@@ -625,7 +617,6 @@ export function applyManagedUpgrade(root, plan, expectedPlanDigest) {
625
617
  applied: false,
626
618
  blocked: false,
627
619
  nothingToApply: true,
628
- optionalStampRefresh: metadataRefresh,
629
620
  });
630
621
  }
631
622
  throw new Error('managed upgrade plan digest mismatch; run a new preview and use its exact nextCommand');
@@ -741,31 +732,18 @@ export function renderManagedUpgrade(plan, options = {}) {
741
732
  const summary = plan.summary;
742
733
  const managedAssets = summary.managedAssets ?? summary.total ?? plan.assets.length;
743
734
  const wouldWrite = summary.wouldWrite ?? 0;
744
- const metadataRefresh = summary.metadataRefresh ?? 0;
745
735
  const customizedPreserved = summary.customizedPreserved ?? summary.states?.customized ?? 0;
746
736
  const blocked = summary.blocked ?? 0;
747
737
  console.log(
748
738
  `Managed assets: ${managedAssets}; would write: ${wouldWrite}; ` +
749
- `customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}` +
750
- (metadataRefresh > 0 ? `; optional stamp refresh: ${metadataRefresh}` : '') +
751
- '.'
739
+ `customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}.`
752
740
  );
753
741
  if (plan.applied) {
754
- // Distinguish content writes from optional stamp/metadata bookkeeping.
755
- if (wouldWrite === 0 && metadataRefresh > 0) {
756
- console.log(
757
- `Refreshed ${metadataRefresh} version stamp(s)` +
758
- (summary.manifestChanged ? ' and managed manifest' : '') +
759
- ' (no content body changes).'
760
- );
761
- } else {
762
- console.log(
763
- `Applied ${wouldWrite} content write(s)` +
764
- (metadataRefresh > 0 ? `, ${metadataRefresh} stamp refresh(es)` : '') +
765
- (summary.manifestChanged ? ', managed manifest' : '') +
766
- '.'
767
- );
768
- }
742
+ console.log(
743
+ `Applied ${wouldWrite} content write(s)` +
744
+ (summary.manifestChanged ? ', managed manifest' : '') +
745
+ '.'
746
+ );
769
747
  return;
770
748
  }
771
749
  // Content already matches package templates — do not urge --apply as the primary next step.
@@ -775,15 +753,6 @@ export function renderManagedUpgrade(plan, options = {}) {
775
753
  console.log(
776
754
  `Nothing to apply — managed content matches ${verLabel} (${customizedPreserved} customized preserved).`
777
755
  );
778
- if (metadataRefresh > 0) {
779
- console.log(
780
- `Optional: ${metadataRefresh} skill stamp(s) lag package version while content is already current.`
781
- );
782
- const stampCmd = options.optionalStampApply ?? options.next;
783
- if (stampCmd) {
784
- console.log(`Optional stamp-only apply (not required): ${stampCmd}`);
785
- }
786
- }
787
756
  return;
788
757
  }
789
758
  console.log(`Planned writes: ${wouldWrite}; blocked conflicts/deletions: ${blocked}.`);