arkgate 4.1.1 → 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 (56) hide show
  1. package/CHANGELOG.md +81 -3
  2. package/README.md +15 -4
  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/ci-and-commands.mjs +16 -7
  7. package/bin/lib/codex-home.mjs +90 -8
  8. package/bin/lib/design-smells.mjs +71 -9
  9. package/bin/lib/doctor-plan.mjs +36 -36
  10. package/bin/lib/effective-contract-load.mjs +73 -9
  11. package/bin/lib/enforcement-state.mjs +1 -1
  12. package/bin/lib/gate-files.mjs +441 -9
  13. package/bin/lib/github-enforcement.mjs +16 -3
  14. package/bin/lib/hook-templates.mjs +12 -11
  15. package/bin/lib/html-report-evolution.mjs +114 -0
  16. package/bin/lib/html-report.mjs +11 -89
  17. package/bin/lib/import-resolve.mjs +33 -11
  18. package/bin/lib/install-activation.mjs +87 -0
  19. package/bin/lib/install-migrate.mjs +66 -50
  20. package/bin/lib/managed-upgrade.mjs +10 -41
  21. package/bin/lib/mcp-adoption.mjs +15 -5
  22. package/bin/lib/pilot-loop.mjs +25 -8
  23. package/bin/lib/project-identity.mjs +103 -0
  24. package/bin/lib/report-snapshot-context.mjs +28 -0
  25. package/bin/lib/resident-hook.mjs +33 -9
  26. package/bin/lib/rules-inventory.mjs +100 -8
  27. package/bin/lib/skill-install.mjs +272 -22
  28. package/bin/lib/skill-write.mjs +899 -0
  29. package/bin/lib/start-preview.mjs +84 -1
  30. package/bin/lib/upgrade-command.mjs +2 -5
  31. package/dist/index.cjs +13 -13
  32. package/dist/index.d.ts +194 -2
  33. package/dist/index.js +13 -13
  34. package/docs/README.md +5 -3
  35. package/docs/agent-guide.md +110 -14
  36. package/docs/ai-gates.md +103 -18
  37. package/docs/assets/ark-write-gate.svg +2 -2
  38. package/docs/enthusiast/how-to-agent-gates.md +6 -0
  39. package/docs/package-surface.md +14 -9
  40. package/docs/product-voice.md +13 -1
  41. package/package.json +3 -1
  42. package/schemas/ark.project-identity.schema.json +116 -0
  43. package/server.json +2 -2
  44. package/templates/skills/ark-adopt.md +9 -0
  45. package/templates/skills/ark-architect.md +12 -2
  46. package/templates/skills/ark-autopilot.md +9 -0
  47. package/templates/skills/ark-contract.md +11 -1
  48. package/templates/skills/ark-coverage.md +9 -0
  49. package/templates/skills/ark-explain.md +13 -1
  50. package/templates/skills/ark-explore.md +9 -0
  51. package/templates/skills/ark-fix.md +10 -1
  52. package/templates/skills/ark-loop.md +11 -2
  53. package/templates/skills/ark-place.md +17 -6
  54. package/templates/skills/ark-runtime.md +8 -0
  55. package/templates/skills/ark-think.md +14 -2
  56. package/templates/skills/ark-upgrade.md +9 -0
@@ -0,0 +1,114 @@
1
+ export function renderEvolutionSection({
2
+ originSnapshot,
3
+ currentSnapshot,
4
+ originJustCreated,
5
+ esc,
6
+ formatDelta,
7
+ historyMax,
8
+ }) {
9
+ if (!currentSnapshot) return '';
10
+ if (originJustCreated || !originSnapshot) {
11
+ return `<div class="section card evolve">
12
+ <h2>Origin baseline captured</h2>
13
+ <p class="dim" style="margin:.2rem 0 0;font-size:.9rem">
14
+ This is the <b>first</b> architecture snapshot for this project
15
+ (<code>.ark/reports/origin.json</code> + <code>origin.html</code>).
16
+ Future reports will show deltas against this starting point so you can prove evolution.
17
+ </p>
18
+ </div>`;
19
+ }
20
+ const scoreComparable =
21
+ typeof originSnapshot.arkVersion === 'string' &&
22
+ originSnapshot.arkVersion.length > 0 &&
23
+ originSnapshot.arkVersion === currentSnapshot.arkVersion;
24
+ const rows = [
25
+ ['Ark score', originSnapshot.score, currentSnapshot.score, '', scoreComparable],
26
+ ['Governed %', originSnapshot.governedPercent, currentSnapshot.governedPercent, 'pp', true],
27
+ ['Files in scope', originSnapshot.totalFiles, currentSnapshot.totalFiles, '', true],
28
+ ['Classified files', originSnapshot.classifiedFiles, currentSnapshot.classifiedFiles, '', true],
29
+ ['Active violations', originSnapshot.activeViolations, currentSnapshot.activeViolations, '', true],
30
+ ['Value violations', originSnapshot.valueViolations, currentSnapshot.valueViolations, '', true],
31
+ ['Type-only violations', originSnapshot.typeOnlyViolations, currentSnapshot.typeOnlyViolations, '', true],
32
+ ['Layers', originSnapshot.layerCount, currentSnapshot.layerCount, '', true],
33
+ ['Deny rules', originSnapshot.denyRules, currentSnapshot.denyRules, '', true],
34
+ ['Gates configured', originSnapshot.gatesOn, currentSnapshot.gatesOn, '', true],
35
+ ];
36
+ const originDate = (originSnapshot.generatedAt || '').slice(0, 10) || 'origin';
37
+ const nowDate = (currentSnapshot.generatedAt || '').slice(0, 10) || 'now';
38
+ const tr = rows
39
+ .map(([label, from, to, unit, comparable]) => {
40
+ const d =
41
+ comparable && typeof from === 'number' && typeof to === 'number'
42
+ ? to - from
43
+ : null;
44
+ const good =
45
+ label.includes('violation') || label.includes('Violation')
46
+ ? d != null && d <= 0
47
+ : label.includes('Governed') ||
48
+ label.includes('score') ||
49
+ label.includes('Classified') ||
50
+ label.includes('Gates')
51
+ ? d != null && d >= 0
52
+ : null;
53
+ const cls =
54
+ d == null || d === 0 ? 'flat' : good === true ? 'up' : good === false ? 'down' : 'flat';
55
+ const delta =
56
+ d == null
57
+ ? '—'
58
+ : unit === 'pp'
59
+ ? formatDelta(Math.round(d * 10) / 10, { suffix: ' pp' })
60
+ : formatDelta(d);
61
+ return `<tr>
62
+ <td>${esc(label)}</td>
63
+ <td class="num">${from ?? '—'}</td>
64
+ <td class="num">${to ?? '—'}</td>
65
+ <td class="num delta ${cls}">${esc(delta)}</td>
66
+ </tr>`;
67
+ })
68
+ .join('\n');
69
+ const originLayers = originSnapshot.layerFiles || {};
70
+ const currentLayers = currentSnapshot.layerFiles || {};
71
+ const layerKeys = [
72
+ ...new Set([...Object.keys(originLayers), ...Object.keys(currentLayers)]),
73
+ ].sort();
74
+ const layerTr = layerKeys
75
+ .map((name) => {
76
+ const from = originLayers[name] || 0;
77
+ const to = currentLayers[name] || 0;
78
+ const d = to - from;
79
+ const cls = d === 0 ? 'flat' : d > 0 ? 'up' : 'down';
80
+ return `<tr>
81
+ <td class="ln">${esc(name)}</td>
82
+ <td class="num">${from}</td>
83
+ <td class="num">${to}</td>
84
+ <td class="num delta ${cls}">${esc(formatDelta(d))}</td>
85
+ </tr>`;
86
+ })
87
+ .join('\n');
88
+ const scoreNote = scoreComparable
89
+ ? ''
90
+ : `<p class="dim" style="margin:-.35rem 0 .75rem;font-size:.88rem">
91
+ Ark score is not comparable across Ark versions
92
+ (<code>${esc(originSnapshot.arkVersion ?? 'unknown')}</code> →
93
+ <code>${esc(currentSnapshot.arkVersion ?? 'unknown')}</code>); its Δ is shown as —.
94
+ Raw coverage, files, violations, layers, rules, and gate metrics remain visible.
95
+ </p>`;
96
+ return `<div class="section card evolve">
97
+ <h2>Evolution vs origin</h2>
98
+ <p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">
99
+ Origin snapshot <code>${esc(originDate)}</code> → this report <code>${esc(nowDate)}</code>
100
+ · frozen at <code>.ark/reports/origin.*</code> · reopen origin HTML anytime for the starting picture.
101
+ </p>
102
+ ${scoreNote}
103
+ <table class="layers">
104
+ <tr><th>Metric</th><th>Origin</th><th>Now</th><th>Δ</th></tr>
105
+ ${tr}
106
+ </table>
107
+ <h3>Files per layer</h3>
108
+ <table class="layers">
109
+ <tr><th>Layer</th><th>Origin</th><th>Now</th><th>Δ</th></tr>
110
+ ${layerTr || '<tr><td colspan="4" class="dim">No layer file data in snapshots.</td></tr>'}
111
+ </table>
112
+ <p class="legend">Green Δ = improvement for that metric (↑ coverage/score/gates, ↓ violations). Score Δ is comparable only within the same Ark version. History JSON under <code>.ark/reports/history/</code> (last ${historyMax}).</p>
113
+ </div>`;
114
+ }
@@ -21,7 +21,9 @@ 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';
24
25
  import { arkGitignoreAppendDecision } from './ark-gitignore.mjs';
26
+ import { captureGitSnapshot } from './report-snapshot-context.mjs';
25
27
 
26
28
  export { arkGitignoreAppendDecision, gitignoreCoversArkState, gitignoreHasArkNegationException } from './ark-gitignore.mjs';
27
29
 
@@ -161,6 +163,7 @@ export function buildReportSnapshot({
161
163
  return path.basename(root);
162
164
  }
163
165
  })(),
166
+ git: captureGitSnapshot(root),
164
167
  ok: Boolean(ok),
165
168
  mode: mode ?? null,
166
169
  score: score ?? null,
@@ -1228,95 +1231,14 @@ export function renderHtmlReport({
1228
1231
  <div class="gates">${enforcementRows}</div>
1229
1232
  </div>
1230
1233
 
1231
- ${(() => {
1232
- if (!currentSnapshot) return '';
1233
- // First report: originSnapshot is null at render time (written to disk just after).
1234
- if (originJustCreated || !originSnapshot) {
1235
- return `<div class="section card evolve">
1236
- <h2>Origin baseline captured</h2>
1237
- <p class="dim" style="margin:.2rem 0 0;font-size:.9rem">
1238
- This is the <b>first</b> architecture snapshot for this project
1239
- (<code>.ark/reports/origin.json</code> + <code>origin.html</code>).
1240
- Future reports will show deltas against this starting point so you can prove evolution.
1241
- </p>
1242
- </div>`;
1243
- }
1244
- const rows = [
1245
- ['Ark score', originSnapshot.score, currentSnapshot.score, ''],
1246
- ['Governed %', originSnapshot.governedPercent, currentSnapshot.governedPercent, 'pp'],
1247
- ['Files in scope', originSnapshot.totalFiles, currentSnapshot.totalFiles, ''],
1248
- ['Classified files', originSnapshot.classifiedFiles, currentSnapshot.classifiedFiles, ''],
1249
- ['Active violations', originSnapshot.activeViolations, currentSnapshot.activeViolations, ''],
1250
- ['Value violations', originSnapshot.valueViolations, currentSnapshot.valueViolations, ''],
1251
- ['Type-only violations', originSnapshot.typeOnlyViolations, currentSnapshot.typeOnlyViolations, ''],
1252
- ['Layers', originSnapshot.layerCount, currentSnapshot.layerCount, ''],
1253
- ['Deny rules', originSnapshot.denyRules, currentSnapshot.denyRules, ''],
1254
- ['Gates configured', originSnapshot.gatesOn, currentSnapshot.gatesOn, ''],
1255
- ];
1256
- const originDate = (originSnapshot.generatedAt || '').slice(0, 10) || 'origin';
1257
- const nowDate = (currentSnapshot.generatedAt || '').slice(0, 10) || 'now';
1258
- const tr = rows
1259
- .map(([label, from, to, unit]) => {
1260
- const d =
1261
- typeof from === 'number' && typeof to === 'number' ? to - from : null;
1262
- const good =
1263
- label.includes('violation') || label.includes('Violation')
1264
- ? d != null && d <= 0
1265
- : label.includes('Governed') || label.includes('score') || label.includes('Classified') || label.includes('Gates')
1266
- ? d != null && d >= 0
1267
- : null;
1268
- const cls =
1269
- d == null || d === 0 ? 'flat' : good === true ? 'up' : good === false ? 'down' : 'flat';
1270
- const delta =
1271
- d == null
1272
- ? '—'
1273
- : unit === 'pp'
1274
- ? formatDelta(Math.round(d * 10) / 10, { suffix: ' pp' })
1275
- : formatDelta(d);
1276
- return `<tr>
1277
- <td>${esc(label)}</td>
1278
- <td class="num">${from ?? '—'}</td>
1279
- <td class="num">${to ?? '—'}</td>
1280
- <td class="num delta ${cls}">${esc(delta)}</td>
1281
- </tr>`;
1282
- })
1283
- .join('\n');
1284
- // Layer file deltas
1285
- const originLayers = originSnapshot.layerFiles || {};
1286
- const currentLayers = currentSnapshot.layerFiles || {};
1287
- const layerKeys = [...new Set([...Object.keys(originLayers), ...Object.keys(currentLayers)])].sort();
1288
- const layerTr = layerKeys
1289
- .map((name) => {
1290
- const from = originLayers[name] || 0;
1291
- const to = currentLayers[name] || 0;
1292
- const d = to - from;
1293
- const cls = d === 0 ? 'flat' : d > 0 ? 'up' : 'down';
1294
- return `<tr>
1295
- <td class="ln">${esc(name)}</td>
1296
- <td class="num">${from}</td>
1297
- <td class="num">${to}</td>
1298
- <td class="num delta ${cls}">${esc(formatDelta(d))}</td>
1299
- </tr>`;
1300
- })
1301
- .join('\n');
1302
- return `<div class="section card evolve">
1303
- <h2>Evolution vs origin</h2>
1304
- <p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">
1305
- Origin snapshot <code>${esc(originDate)}</code> → this report <code>${esc(nowDate)}</code>
1306
- · frozen at <code>.ark/reports/origin.*</code> · reopen origin HTML anytime for the starting picture.
1307
- </p>
1308
- <table class="layers">
1309
- <tr><th>Metric</th><th>Origin</th><th>Now</th><th>Δ</th></tr>
1310
- ${tr}
1311
- </table>
1312
- <h3>Files per layer</h3>
1313
- <table class="layers">
1314
- <tr><th>Layer</th><th>Origin</th><th>Now</th><th>Δ</th></tr>
1315
- ${layerTr || '<tr><td colspan="4" class="dim">No layer file data in snapshots.</td></tr>'}
1316
- </table>
1317
- <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>
1318
- </div>`;
1319
- })()}
1234
+ ${renderEvolutionSection({
1235
+ originSnapshot,
1236
+ currentSnapshot,
1237
+ originJustCreated,
1238
+ esc,
1239
+ formatDelta,
1240
+ historyMax: ARK_REPORT_HISTORY_MAX,
1241
+ })}
1320
1242
 
1321
1243
  <div class="section card senior">
1322
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):');