arkgate 4.1.1 → 4.2.1

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 (58) hide show
  1. package/CHANGELOG.md +107 -3
  2. package/README.md +16 -4
  3. package/bin/ark-check-runtime.mjs +16 -5
  4. package/bin/ark-mcp-runtime.mjs +766 -64
  5. package/bin/ark-shared.mjs +16 -4
  6. package/bin/lib/agent-gates.mjs +1 -0
  7. package/bin/lib/ci-and-commands.mjs +16 -7
  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 +36 -36
  11. package/bin/lib/effective-contract-load.mjs +73 -9
  12. package/bin/lib/enforcement-state.mjs +1 -1
  13. package/bin/lib/gate-files.mjs +441 -9
  14. package/bin/lib/github-enforcement.mjs +16 -3
  15. package/bin/lib/hook-templates.mjs +12 -11
  16. package/bin/lib/html-report-evolution.mjs +114 -0
  17. package/bin/lib/html-report.mjs +11 -89
  18. package/bin/lib/import-resolve.mjs +33 -11
  19. package/bin/lib/install-activation.mjs +87 -0
  20. package/bin/lib/install-migrate.mjs +66 -50
  21. package/bin/lib/managed-upgrade.mjs +10 -41
  22. package/bin/lib/mcp-adoption.mjs +15 -5
  23. package/bin/lib/physical-cohesion.mjs +2 -1
  24. package/bin/lib/pilot-loop.mjs +25 -8
  25. package/bin/lib/project-identity.mjs +103 -0
  26. package/bin/lib/report-snapshot-context.mjs +28 -0
  27. package/bin/lib/resident-hook.mjs +33 -9
  28. package/bin/lib/rules-inventory.mjs +100 -8
  29. package/bin/lib/skill-install.mjs +272 -22
  30. package/bin/lib/skill-write.mjs +899 -0
  31. package/bin/lib/start-preview.mjs +84 -1
  32. package/bin/lib/upgrade-command.mjs +2 -5
  33. package/dist/index.cjs +13 -13
  34. package/dist/index.d.ts +194 -2
  35. package/dist/index.js +13 -13
  36. package/docs/README.md +5 -3
  37. package/docs/agent-guide.md +110 -14
  38. package/docs/ai-gates.md +103 -18
  39. package/docs/assets/ark-write-gate.svg +2 -2
  40. package/docs/enthusiast/how-to-agent-gates.md +6 -0
  41. package/docs/package-surface.md +15 -9
  42. package/docs/product-voice.md +13 -1
  43. package/package.json +7 -1
  44. package/schemas/ark.project-identity.schema.json +116 -0
  45. package/server.json +2 -2
  46. package/templates/skills/ark-adopt.md +9 -0
  47. package/templates/skills/ark-architect.md +12 -2
  48. package/templates/skills/ark-autopilot.md +9 -0
  49. package/templates/skills/ark-contract.md +11 -1
  50. package/templates/skills/ark-coverage.md +9 -0
  51. package/templates/skills/ark-explain.md +13 -1
  52. package/templates/skills/ark-explore.md +9 -0
  53. package/templates/skills/ark-fix.md +10 -1
  54. package/templates/skills/ark-loop.md +11 -2
  55. package/templates/skills/ark-place.md +17 -6
  56. package/templates/skills/ark-runtime.md +8 -0
  57. package/templates/skills/ark-think.md +14 -2
  58. package/templates/skills/ark-upgrade.md +9 -0
@@ -67,20 +67,44 @@ function residentRuntimeIdentity(launcher) {
67
67
  return hash.digest('hex');
68
68
  }
69
69
 
70
+ function realpathOrResolve(value) {
71
+ const resolved = path.resolve(value);
72
+ try { return fs.realpathSync(resolved); } catch { return resolved; }
73
+ }
74
+
75
+ export function residentInvocationIdentity({ root, config, manifest, tsconfig }) {
76
+ const lexicalRoot = path.resolve(root);
77
+ const realRoot = realpathOrResolve(lexicalRoot);
78
+ const projectPath = (value) => {
79
+ if (!value) return null;
80
+ const absolute = path.isAbsolute(value)
81
+ ? path.resolve(value)
82
+ : path.resolve(lexicalRoot, value);
83
+ const relative = path.relative(lexicalRoot, absolute);
84
+ const contained =
85
+ relative === '' ||
86
+ (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
87
+ return realpathOrResolve(contained ? path.resolve(realRoot, relative) : absolute);
88
+ };
89
+ return {
90
+ root: realRoot,
91
+ config: projectPath(config),
92
+ manifest: projectPath(manifest),
93
+ tsconfig: projectPath(tsconfig),
94
+ };
95
+ }
96
+
70
97
  export function residentHookEndpoint({ root, config, manifest, tsconfig, launcher }) {
71
- let realRoot;
72
- try { realRoot = fs.realpathSync(root); } catch { realRoot = path.resolve(root); }
98
+ const invocation = residentInvocationIdentity({ root, config, manifest, tsconfig });
99
+ const realLauncher = realpathOrResolve(launcher);
73
100
  const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
74
101
  const directory = path.join(os.tmpdir(), `arkgate-${uid}`);
75
102
  const digest = createHash('sha256').update(JSON.stringify({
76
- root: realRoot,
77
- config: path.resolve(root, config),
78
- manifest: manifest ? path.resolve(root, manifest) : null,
79
- tsconfig: tsconfig ? path.resolve(root, tsconfig) : null,
80
- launcher: path.resolve(launcher),
81
- executable: process.execPath,
103
+ ...invocation,
104
+ launcher: realLauncher,
105
+ executable: realpathOrResolve(process.execPath),
82
106
  protocolVersion: RESIDENT_HOOK_PROTOCOL_VERSION,
83
- runtimeIdentity: residentRuntimeIdentity(path.resolve(launcher)),
107
+ runtimeIdentity: residentRuntimeIdentity(realLauncher),
84
108
  })).digest('hex').slice(0, 24);
85
109
  return {
86
110
  directory,
@@ -11,11 +11,63 @@
11
11
  function lineOf(content, index) {
12
12
  return content.slice(0, index).split('\n').length;
13
13
  }
14
+ function normalizeInventoryPath(file) {
15
+ return file.replace(/\\/g, '/').replace(/^\.\//, '');
16
+ }
17
+ function ownsIntent(intentPrefixes, intentRoots) {
18
+ return intentPrefixes.some((prefix) => {
19
+ const normalized = prefix.trim().replace(/\.+$/, '');
20
+ return intentRoots.some((root) => normalized === root || normalized.startsWith(`${root}.`));
21
+ });
22
+ }
23
+ function isDomainLayer(layer, intentPrefixes = []) {
24
+ return (/domain|entity|aggregate|model/i.test(layer) ||
25
+ ownsIntent(intentPrefixes, ['Domain']));
26
+ }
27
+ function isControllerEligibleLayer(layer, intentPrefixes = []) {
28
+ return (/application|orchestration|presentation|adapter|framework|interface|delivery|transport|inbound|controller/i.test(layer) ||
29
+ ownsIntent(intentPrefixes, [
30
+ 'Application',
31
+ 'Orchestration',
32
+ 'Presentation',
33
+ 'Adapter',
34
+ 'Interface',
35
+ 'Delivery',
36
+ 'Transport',
37
+ ]));
38
+ }
39
+ function isNonPilotSurface(file) {
40
+ return (/(?:^|\/)(?:tests?|__tests__|fixtures?|testdata|mocks?|stubs?|examples?|samples?|seeds?|seeders?|migrations?|excluded|exclusions?)(?:\/|$)/i.test(file) ||
41
+ /(?:^|\/)[^/]*\.(?:test|spec|fixture|mock|stub|seed|seeder)\.[^/]+$/i.test(file) ||
42
+ /(?:^|\/)(?:seed|seeder|fixture|mock|stub)\.[^/]+$/i.test(file));
43
+ }
14
44
  export function buildRulesInventory(input) {
15
45
  const candidates = [];
16
46
  let seq = 0;
47
+ const fileLayers = new Map(Object.entries(input.fileLayers ?? {}).map(([file, layer]) => [
48
+ normalizeInventoryPath(file),
49
+ layer,
50
+ ]));
51
+ const layerIntentPrefixes = new Map((input.layerContexts ?? []).map((layer) => [
52
+ layer.name,
53
+ layer.intentPrefixes ?? [],
54
+ ]));
55
+ const domainLayer = (input.layerContexts ?? []).find((layer) => isDomainLayer(layer.name, layer.intentPrefixes))?.name ?? 'DomainModel';
17
56
  for (const [file, content] of Object.entries(input.fileContents).sort(([a], [b]) => a.localeCompare(b))) {
18
- const posix = file.replace(/\\/g, '/');
57
+ const posix = normalizeInventoryPath(file);
58
+ // Test data, fixtures, seeds, migrations, and explicit exclusions may retain
59
+ // representative smells, but are not production extraction pilots.
60
+ if (isNonPilotSurface(posix))
61
+ continue;
62
+ // Generated mirrors are evidence for their canonical source, not a second
63
+ // extraction candidate.
64
+ if (/GENERATED FILE\s+[—-]\s+do not edit by hand/i.test(content.slice(0, 320)))
65
+ continue;
66
+ const hasGovernedLayer = fileLayers.has(posix);
67
+ const governedLayer = fileLayers.get(posix);
68
+ const governedIntentPrefixes = governedLayer
69
+ ? layerIntentPrefixes.get(governedLayer) ?? []
70
+ : [];
19
71
  // P2-N — clear UI bags only (components/theme/styles). Do NOT blanket-skip all
20
72
  // app/pages (server actions / route handlers live there and stay inventoriable).
21
73
  const isUiChrome = /(?:^|\/)(?:components|ui|layouts|styles|hooks|theme|tokens|i18n|locales?)(?:\/|$)/i.test(posix) ||
@@ -23,14 +75,22 @@ export function buildRulesInventory(input) {
23
75
  /(?:page|layout|loading|error|template|default)\.(?:tsx|jsx)$/i.test(posix);
24
76
  const isApiRoute = /(?:^|\/)(?:app|pages)(?:\/[^/]+)*\/api(?:\/|$)/i.test(posix);
25
77
  const isServerAction = /(?:^|\/)actions?(?:\/|\.|$)/i.test(posix) || /['"]use server['"]/.test(content);
26
- const isController = /controller|handler|resolver/i.test(file) ||
78
+ const controllerShape = /controller|handler|resolver/i.test(file) ||
27
79
  isApiRoute ||
28
80
  isServerAction ||
29
81
  (/route\.(?:ts|js|tsx|jsx)$/i.test(posix) && !isUiChrome) ||
30
82
  /@(Controller|Get|Post|Put|Delete|Patch)\b/.test(content) ||
31
83
  /\bexport\s+(?:async\s+)?function\s+(?:GET|POST|PUT|DELETE|PATCH)\b/.test(content) ||
32
84
  /\bexport\s+const\s+(?:GET|POST|PUT|DELETE|PATCH)\s*=/.test(content);
33
- const isDomain = /domain|entity|aggregate|model/i.test(file);
85
+ const isController = hasGovernedLayer
86
+ ? Boolean(governedLayer &&
87
+ isControllerEligibleLayer(governedLayer, governedIntentPrefixes) &&
88
+ controllerShape)
89
+ : controllerShape;
90
+ const isDomain = hasGovernedLayer
91
+ ? Boolean(governedLayer && isDomainLayer(governedLayer, governedIntentPrefixes))
92
+ : /domain|entity|aggregate|model/i.test(file);
93
+ const magicConstantEligible = !hasGovernedLayer || isDomain || isController;
34
94
  // validation-in-controller (API/Nest/server-action handlers — not pure UI chrome)
35
95
  if (isController && !isUiChrome) {
36
96
  const valRe = /\b(if\s*\([^)]{0,80}(amount|total|price|qty|quantity|balance)[^)]{0,40}\)|throw new (Error|BadRequest|ValidationError)|z\.object\(|yup\.|class-validator|@Is[A-Z])/g;
@@ -44,8 +104,9 @@ export function buildRulesInventory(input) {
44
104
  line: lineOf(content, m.index),
45
105
  message: 'Business validation appears in a controller/handler — extract an invariant or Domain rule.',
46
106
  confidence: 'direct-evidence',
107
+ governedLayer,
47
108
  suggestedArkRule: {
48
- layer: 'DomainModel',
109
+ layer: domainLayer,
49
110
  invariantId: `INV-EXTRACT-${seq}`,
50
111
  sensor: 'invariant-coverage',
51
112
  },
@@ -67,9 +128,21 @@ export function buildRulesInventory(input) {
67
128
  // Narrow DEFAULT_/REQUEST_/STORAGE_ — only known infra tokens, not all DEFAULT_* seeds
68
129
  /^(?:DEFAULT_(?:BASE_URL|TIMEOUT(?:_MS)?|RETRY|PORT|HOST|HEADERS?|CACHE|TTL|MS|LOCALE|LANG|TIMEZONE|TZ)|REQUEST_(?:TIMEOUT(?:_MS)?|HEADERS?|RETRY|ID_PREFIX)|STORAGE_(?:KEY|PREFIX|BUCKET)|DAY_MS$|APP_DOMAIN$|BASE_URL$)$/i.test(name) ||
69
130
  // Known I/O bag prefixes that are never domain seeds in field clones
70
- /^(?:FAVORITES_STORAGE|LISTINGS_CACHE|DOCS_PATH|METRICS_INTERVAL)/i.test(name);
131
+ /^(?:FAVORITES_STORAGE|LISTINGS_CACHE|DOCS_PATH|METRICS_INTERVAL)/i.test(name) ||
132
+ // Development identities and PostgreSQL type OIDs are technical wiring, not
133
+ // business literals. Keep this narrow so Domain limits/status seeds still surface.
134
+ /^(?:DEV|DEMO|SEED|FIXTURE)_[A-Z0-9_]+$/i.test(name) ||
135
+ /^(?:PG|POSTGRES|OID)_[A-Z0-9_]+$/i.test(name) ||
136
+ /_(?:OID|OIDS)$/i.test(name) ||
137
+ /^(?:INT2|INT4|INT8|FLOAT4|FLOAT8|NUMERIC|DATE|TIME|TIMESTAMP|TIMESTAMPTZ|JSON|JSONB|UUID)OID$/i.test(name) ||
138
+ /(?:^|_)(?:SCHEMA|PROTOCOL|RESOLVER|FORMAT)_(?:URL|URI|VERSION|ID|IDENTITY)$/i.test(name);
71
139
  while ((magic = magicRe.exec(content)) !== null) {
72
140
  const name = magic[2];
141
+ // With governed layer evidence, generic Tooling/Kernel constants are not
142
+ // business-rule candidates. Controller-shaped boundaries stay eligible
143
+ // because business policy can leak into them.
144
+ if (!magicConstantEligible)
145
+ continue;
73
146
  if (isInfraMagicName(name))
74
147
  continue;
75
148
  // P2-N: skip remaining ALL_CAPS noise only on clear UI chrome (not all of app/).
@@ -88,7 +161,8 @@ export function buildRulesInventory(input) {
88
161
  line: lineOf(content, magic.index),
89
162
  message: `Magic business constant ${name} may belong in a Domain policy or invariant catalog.`,
90
163
  confidence: 'heuristic',
91
- suggestedArkRule: { layer: 'DomainModel', invariantId: `INV-${name}` },
164
+ governedLayer,
165
+ suggestedArkRule: { layer: domainLayer, invariantId: `INV-${name}` },
92
166
  neverMechanicalSafe: true,
93
167
  });
94
168
  }
@@ -109,8 +183,9 @@ export function buildRulesInventory(input) {
109
183
  line: lineOf(content, c.index),
110
184
  message: `Class ${c[1]} looks anemic (data-heavy, few behaviors).`,
111
185
  confidence: 'heuristic',
186
+ governedLayer,
112
187
  suggestedArkRule: {
113
- layer: 'DomainModel',
188
+ layer: domainLayer,
114
189
  structureId: 'no-anemic-model',
115
190
  sensor: 'no-anemic-model',
116
191
  },
@@ -131,6 +206,16 @@ export function buildRulesInventory(input) {
131
206
  const mutRe = /this\.\w+\s*=/g;
132
207
  let mut;
133
208
  while ((mut = mutRe.exec(content)) !== null) {
209
+ const classStart = content.lastIndexOf('class ', mut.index);
210
+ const classHeaderEnd = classStart >= 0 ? content.indexOf('{', classStart) : -1;
211
+ const classHeader = classStart >= 0 && classHeaderEnd >= classStart && classHeaderEnd < mut.index
212
+ ? content.slice(classStart, classHeaderEnd)
213
+ : '';
214
+ // Error metadata assignment is constructor wiring, not aggregate
215
+ // mutation. Keep the exclusion local to the containing class header.
216
+ if (/\bextends\s+(?:Error|[A-Za-z_$][A-Za-z0-9_$]*Error)\b/.test(classHeader)) {
217
+ continue;
218
+ }
134
219
  const window = content.slice(Math.max(0, mut.index - 200), mut.index + 200);
135
220
  if (!/\b(ensureInvariants|assertInvariants|validate|publish|emit)\b/.test(window)) {
136
221
  seq += 1;
@@ -141,8 +226,9 @@ export function buildRulesInventory(input) {
141
226
  line: lineOf(content, mut.index),
142
227
  message: 'Domain field mutation without nearby guard/publish call.',
143
228
  confidence: 'heuristic',
229
+ governedLayer,
144
230
  suggestedArkRule: {
145
- layer: 'DomainModel',
231
+ layer: domainLayer,
146
232
  structureId: 'events-on-mutation',
147
233
  sensor: 'domain-event-on-mutation',
148
234
  },
@@ -155,6 +241,12 @@ export function buildRulesInventory(input) {
155
241
  }
156
242
  }
157
243
  const contracted = new Set(input.contractedRuleIds ?? []);
244
+ candidates.sort((a, b) => Number(b.confidence === 'direct-evidence') -
245
+ Number(a.confidence === 'direct-evidence') ||
246
+ a.file.localeCompare(b.file) ||
247
+ a.line - b.line ||
248
+ a.kind.localeCompare(b.kind) ||
249
+ a.id.localeCompare(b.id));
158
250
  const underContract = candidates.filter((c) => (c.suggestedArkRule?.invariantId && contracted.has(c.suggestedArkRule.invariantId)) ||
159
251
  (c.suggestedArkRule?.structureId && contracted.has(c.suggestedArkRule.structureId))).length;
160
252
  return {
@@ -224,10 +224,12 @@ export function arkPackageVersion() {
224
224
  }
225
225
 
226
226
  // Insert `arkVersion: <v>` into a skill's YAML frontmatter (before its closing
227
- // `---`). No frontmatter → returned unchanged. Idempotent for a given version.
227
+ // `---`). No frontmatter → returned unchanged. Idempotent for a given version
228
+ // and preserves the checked-out line ending on Windows.
228
229
  export function stampSkill(content, version) {
229
230
  if (!version) return content;
230
- const lines = content.split('\n');
231
+ const newline = content.includes('\r\n') ? '\r\n' : '\n';
232
+ const lines = content.split(/\r?\n/);
231
233
  if (lines[0] !== '---') return content;
232
234
  const closeIdx = lines.indexOf('---', 1);
233
235
  if (closeIdx === -1) return content;
@@ -239,7 +241,7 @@ export function stampSkill(content, version) {
239
241
  } else {
240
242
  lines.splice(closeIdx, 0, `arkVersion: ${version}`);
241
243
  }
242
- return lines.join('\n');
244
+ return lines.join(newline);
243
245
  }
244
246
 
245
247
  // Read the `arkVersion:` stamp from an installed skill file. Returns null when
@@ -260,17 +262,61 @@ function skillVersionFromContent(content) {
260
262
  return match ? match[1].trim() : null;
261
263
  }
262
264
 
263
- // Numeric-tuple compare of dotted versions; true when `a` is strictly older than
264
- // `b`. Non-numeric/absent segments compare as 0, so "1.7" < "1.7.5".
265
+ const VERSION_PATTERN =
266
+ /^(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
267
+
268
+ function parseVersion(value, strict) {
269
+ if (typeof value !== 'string') return null;
270
+ const match = value.match(VERSION_PATTERN);
271
+ if (!match || (strict && (match[2] === undefined || match[3] === undefined))) {
272
+ return null;
273
+ }
274
+ const prerelease = match[4]?.split('.') ?? [];
275
+ if (
276
+ prerelease.some(
277
+ (identifier) =>
278
+ /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0')
279
+ )
280
+ ) {
281
+ return null;
282
+ }
283
+ return {
284
+ core: [match[1], match[2] ?? '0', match[3] ?? '0'],
285
+ prerelease,
286
+ };
287
+ }
288
+
289
+ /** True only for a complete SemVer 2.0.0 version. */
290
+ export function isValidSemver(value) {
291
+ return parseVersion(value, true) !== null;
292
+ }
293
+
294
+ // SemVer precedence compare. A one- or two-component numeric core remains
295
+ // accepted for legacy skill stamps, so "1.7" < "1.7.5"; shared catalog metadata
296
+ // uses isValidSemver and therefore requires the complete x.y.z form.
265
297
  export function isVersionOlder(a, b) {
266
- const parse = (v) => String(v).split('.').map((n) => Number.parseInt(n, 10) || 0);
267
- const av = parse(a);
268
- const bv = parse(b);
269
- const len = Math.max(av.length, bv.length);
270
- for (let i = 0; i < len; i += 1) {
271
- const x = av[i] ?? 0;
272
- const y = bv[i] ?? 0;
273
- if (x !== y) return x < y;
298
+ const av = parseVersion(a, false);
299
+ const bv = parseVersion(b, false);
300
+ if (!av || !bv) return false;
301
+ for (let index = 0; index < 3; index += 1) {
302
+ const left = BigInt(av.core[index]);
303
+ const right = BigInt(bv.core[index]);
304
+ if (left !== right) return left < right;
305
+ }
306
+ if (av.prerelease.length === 0 || bv.prerelease.length === 0) {
307
+ return av.prerelease.length > 0 && bv.prerelease.length === 0;
308
+ }
309
+ const length = Math.max(av.prerelease.length, bv.prerelease.length);
310
+ for (let index = 0; index < length; index += 1) {
311
+ const left = av.prerelease[index];
312
+ const right = bv.prerelease[index];
313
+ if (left === undefined || right === undefined) return left === undefined;
314
+ if (left === right) continue;
315
+ const leftNumeric = /^\d+$/.test(left);
316
+ const rightNumeric = /^\d+$/.test(right);
317
+ if (leftNumeric && rightNumeric) return BigInt(left) < BigInt(right);
318
+ if (leftNumeric !== rightNumeric) return leftNumeric;
319
+ return left < right;
274
320
  }
275
321
  return false;
276
322
  }
@@ -315,6 +361,74 @@ export function skillContentMatchesTemplate(installedContent, templateContent) {
315
361
  return installedId === skillContentIdentity(stampSkill(templateContent, '0.0.0'));
316
362
  }
317
363
 
364
+ /**
365
+ * Decide whether one managed skill should be written.
366
+ *
367
+ * Repo catalogs belong to that repo's installed package, so an explicit --force
368
+ * may move them in either direction. Codex home is shared by every repo on the
369
+ * machine: a package older than the installed home stamp must never win, even
370
+ * under --force. In both scopes a version-stamp-only difference is a no-op; the
371
+ * skill body is the capability contract.
372
+ *
373
+ * @param {{
374
+ * existingContent?: string|null,
375
+ * targetContent: string,
376
+ * packageVersion?: string|null,
377
+ * force?: boolean,
378
+ * scope?: 'repo'|'home',
379
+ * }} input
380
+ * @returns {{
381
+ * action: 'write'|'skip',
382
+ * reason: 'missing'|'content-current'|'newer-home-version'|'unknown-source-version'|'existing-preserved'|'content-update',
383
+ * scope: 'repo'|'home',
384
+ * sourceVersion: string|null,
385
+ * installedVersion: string|null,
386
+ * conflict: boolean,
387
+ * downgradeBlocked: boolean,
388
+ * }}
389
+ */
390
+ export function planSkillInstall(input) {
391
+ const scope = input.scope === 'home' ? 'home' : 'repo';
392
+ const existingContent = input.existingContent ?? null;
393
+ const targetContent = String(input.targetContent);
394
+ const sourceVersion =
395
+ input.packageVersion ?? skillVersionFromContent(targetContent);
396
+ const installedVersion = skillVersionFromContent(existingContent);
397
+ const result = (action, reason, conflict = false, downgradeBlocked = false) => ({
398
+ action,
399
+ reason,
400
+ scope,
401
+ sourceVersion,
402
+ installedVersion,
403
+ conflict,
404
+ downgradeBlocked,
405
+ });
406
+
407
+ if (existingContent === null) return result('write', 'missing');
408
+ if (
409
+ existingContent === targetContent ||
410
+ skillContentIdentity(existingContent) === skillContentIdentity(targetContent)
411
+ ) {
412
+ return result('skip', 'content-current');
413
+ }
414
+
415
+ if (scope === 'home') {
416
+ if (installedVersion && !sourceVersion) {
417
+ return result('skip', 'unknown-source-version', true, true);
418
+ }
419
+ if (
420
+ installedVersion &&
421
+ sourceVersion &&
422
+ isVersionOlder(sourceVersion, installedVersion)
423
+ ) {
424
+ return result('skip', 'newer-home-version', true, true);
425
+ }
426
+ }
427
+
428
+ if (!input.force) return result('skip', 'existing-preserved', true);
429
+ return result('write', 'content-update');
430
+ }
431
+
318
432
  /** @returns {Record<string, string>} skill name → template body from package */
319
433
  export function skillTemplateBodies() {
320
434
  return Object.fromEntries(skillTemplates());
@@ -422,6 +536,96 @@ export function assessSkillCatalogParity(skillNames, skillFile, packageVersion,
422
536
  };
423
537
  }
424
538
 
539
+ const CODEX_HOME_CATALOG = '.arkgate-catalog.json';
540
+ const CODEX_HOME_PENDING_CATALOG = '.arkgate-catalog.pending.json';
541
+ const CATALOG_TOKEN_PATTERN =
542
+ /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
543
+
544
+ function readCodexHomeCatalogMetadata(file, kind) {
545
+ try {
546
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
547
+ if (!stat) return { exists: false, valid: false, version: null };
548
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
549
+ return { exists: true, valid: false, version: null };
550
+ }
551
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
552
+ if (kind === 'pending') {
553
+ const keys =
554
+ value && typeof value === 'object' && !Array.isArray(value)
555
+ ? Object.keys(value).sort()
556
+ : [];
557
+ const valid =
558
+ keys.join(',') === 'packageVersion,schemaVersion,token' &&
559
+ value.schemaVersion === '1.0' &&
560
+ isValidSemver(value.packageVersion) &&
561
+ typeof value.token === 'string' &&
562
+ CATALOG_TOKEN_PATTERN.test(value.token);
563
+ return {
564
+ exists: true,
565
+ valid,
566
+ version: valid ? value.packageVersion : null,
567
+ };
568
+ }
569
+ if (
570
+ value?.schemaVersion !== '1.0' ||
571
+ !isValidSemver(value.packageVersion) ||
572
+ !Array.isArray(value.skills)
573
+ ) {
574
+ return { exists: true, valid: false, version: null };
575
+ }
576
+ const seen = new Set();
577
+ for (const skill of value.skills) {
578
+ if (
579
+ !skill ||
580
+ typeof skill.name !== 'string' ||
581
+ !/^ark-[a-z0-9-]+$/.test(skill.name) ||
582
+ typeof skill.contentIdentity !== 'string' ||
583
+ !/^sha256:[a-f0-9]{64}$/.test(skill.contentIdentity) ||
584
+ seen.has(skill.name)
585
+ ) {
586
+ return { exists: true, valid: false, version: null };
587
+ }
588
+ seen.add(skill.name);
589
+ }
590
+ return { exists: true, valid: true, version: value.packageVersion };
591
+ } catch {
592
+ return { exists: true, valid: false, version: null };
593
+ }
594
+ }
595
+
596
+ function codexHomeCatalogState(skillsDir) {
597
+ const catalog = readCodexHomeCatalogMetadata(
598
+ path.join(skillsDir, CODEX_HOME_CATALOG),
599
+ 'catalog'
600
+ );
601
+ const pending = readCodexHomeCatalogMetadata(
602
+ path.join(skillsDir, CODEX_HOME_PENDING_CATALOG),
603
+ 'pending'
604
+ );
605
+ let floorVersion = catalog.version;
606
+ if (
607
+ pending.version &&
608
+ (!floorVersion || isVersionOlder(floorVersion, pending.version))
609
+ ) {
610
+ floorVersion = pending.version;
611
+ }
612
+ return {
613
+ floorVersion,
614
+ pendingVersion: pending.version,
615
+ hasMetadata: catalog.exists || pending.exists,
616
+ metadataInvalid:
617
+ (catalog.exists && !catalog.valid) || (pending.exists && !pending.valid),
618
+ };
619
+ }
620
+
621
+ function newerCodexHomeCatalogVersion(skillsDir, packageVersion, state = null) {
622
+ if (!isValidSemver(packageVersion)) return null;
623
+ const floorVersion = (state ?? codexHomeCatalogState(skillsDir)).floorVersion;
624
+ return floorVersion && isVersionOlder(packageVersion, floorVersion)
625
+ ? floorVersion
626
+ : null;
627
+ }
628
+
425
629
  /**
426
630
  * Repo + home Codex skill parity against the shipping package skill set.
427
631
  * Producer trees (templates/skills) and projects without AGENTS.md return null.
@@ -459,6 +663,14 @@ export function assessCodexSkillParity(root) {
459
663
  const home = assessSkillCatalogParity(skillNames, homeSkill, packageVersion, {
460
664
  legacyFile: homeLegacy,
461
665
  });
666
+ const homeCatalogState = codexHomeCatalogState(skillsDir);
667
+ const newerHomeCatalog = newerCodexHomeCatalogVersion(
668
+ skillsDir,
669
+ packageVersion,
670
+ homeCatalogState
671
+ );
672
+ const pendingRecoveryRequired =
673
+ homeCatalogState.pendingVersion !== null && newerHomeCatalog === null;
462
674
 
463
675
  // Repo catalog matters when .codex is present (Codex host adopted) or repo skills/prompts exist.
464
676
  const repoInPlay =
@@ -467,14 +679,23 @@ export function assessCodexSkillParity(root) {
467
679
  repo.hasLegacyPrompts;
468
680
  // Home is "in play" only when ark skills or legacy prompts were actually installed there
469
681
  // (empty $CODEX_HOME/skills is optional multi-project — not debt).
470
- const homeInPlay = home.presentCount > 0 || home.hasLegacyPrompts;
682
+ const homeInPlay =
683
+ home.presentCount > 0 ||
684
+ home.hasLegacyPrompts ||
685
+ homeCatalogState.hasMetadata;
471
686
 
472
687
  if (!repoInPlay && !homeInPlay) return null;
473
688
 
474
689
  const repoNeedsAttention =
475
690
  repoInPlay && (repo.missing > 0 || repo.stale > 0 || repo.legacyPromptsOnly);
476
691
  const homeNeedsAttention =
477
- homeInPlay && (home.missing > 0 || home.stale > 0 || home.legacyPromptsOnly);
692
+ newerHomeCatalog === null &&
693
+ homeInPlay &&
694
+ (home.missing > 0 ||
695
+ home.stale > 0 ||
696
+ home.legacyPromptsOnly ||
697
+ pendingRecoveryRequired ||
698
+ homeCatalogState.metadataInvalid);
478
699
 
479
700
  return {
480
701
  packageVersion,
@@ -485,6 +706,11 @@ export function assessCodexSkillParity(root) {
485
706
  inPlay: homeInPlay,
486
707
  skillsDir,
487
708
  promptsDir,
709
+ catalogVersion: homeCatalogState.floorVersion,
710
+ catalogNewerThanPackage: newerHomeCatalog !== null,
711
+ pendingCatalogVersion: homeCatalogState.pendingVersion,
712
+ pendingRecoveryRequired,
713
+ catalogMetadataInvalid: homeCatalogState.metadataInvalid,
488
714
  },
489
715
  skillsDir,
490
716
  promptsDir,
@@ -515,6 +741,14 @@ export function detectCodexHomeGap(root) {
515
741
  expectedCount,
516
742
  packageVersion,
517
743
  skillsDir,
744
+ catalogVersion: home.catalogVersion,
745
+ pendingRecoveryRequired: Boolean(home.pendingRecoveryRequired),
746
+ catalogMetadataInvalid: Boolean(home.catalogMetadataInvalid),
747
+ catalogStateReason: home.catalogMetadataInvalid
748
+ ? 'invalid catalog metadata'
749
+ : home.pendingRecoveryRequired
750
+ ? 'interrupted catalog commit'
751
+ : null,
518
752
  };
519
753
  }
520
754
 
@@ -670,20 +904,32 @@ export function detectSkillGaps(root) {
670
904
  return gaps;
671
905
  }
672
906
 
907
+ /**
908
+ * Preserve the full detected inventory for JSON/reporting, but keep immediate
909
+ * human remediation scoped to the host running this process.
910
+ */
911
+ export function skillGapsForActiveHost(skillGaps, env = process.env) {
912
+ const activeHost = detectActiveAgentHost(env);
913
+ if (!activeHost) return skillGaps ?? [];
914
+ return (skillGaps ?? []).filter((gap) => gap.tool === activeHost);
915
+ }
916
+
673
917
  /**
674
918
  * Human-facing skill / Codex catalog gap lines for ark-check (non-JSON).
675
919
  * @param {string} root
676
- * @param {{ skillGaps: object[], codexHomeGap: object|null, codexRepoSkillGap: object|null, codexSessionActive: boolean, color: { dim: Function, yellow: Function } }} opts
920
+ * @param {{ skillGaps: object[], codexHomeGap: object|null, codexRepoSkillGap: object|null, codexSessionActive: boolean, env?: NodeJS.ProcessEnv, color: { dim: Function, yellow: Function } }} opts
677
921
  */
678
922
  export function printSkillAndCodexGapHints(root, opts) {
679
923
  const { skillGaps, codexHomeGap, codexRepoSkillGap, codexSessionActive, color } = opts;
680
- if (skillGaps?.length > 0) {
681
- const legacyCodex = skillGaps.some((gap) => gap.tool === 'codex' && gap.legacyPromptsOnly);
682
- const legacyAdvisory = skillGaps.some(
924
+ const activeSkillGaps = skillGapsForActiveHost(skillGaps, opts.env);
925
+ if (activeSkillGaps.length > 0) {
926
+ const legacyCodex = activeSkillGaps.some(
927
+ (gap) => gap.tool === 'codex' && gap.legacyPromptsOnly
928
+ );
929
+ const legacyAdvisory = activeSkillGaps.some(
683
930
  (gap) => gap.tool === 'codex' && gap.legacyAdvisory && gap.catalogComplete
684
931
  );
685
- // Report Codex legacy separately; never suppress missing/stale for other hosts.
686
- const remaining = skillGaps.filter(
932
+ const remaining = activeSkillGaps.filter(
687
933
  (gap) =>
688
934
  !(gap.tool === 'codex' && (gap.legacyPromptsOnly || gap.legacyAdvisory))
689
935
  );
@@ -727,6 +973,8 @@ export function printSkillAndCodexGapHints(root, opts) {
727
973
  if (codexHomeGap.legacyPromptsOnly) parts.push('legacy-prompts-only');
728
974
  if (codexHomeGap.missing > 0) parts.push(`${codexHomeGap.missing} missing`);
729
975
  if (codexHomeGap.stale > 0) parts.push(`${codexHomeGap.stale} content-behind-package`);
976
+ if (codexHomeGap.pendingRecoveryRequired) parts.push('interrupted catalog commit');
977
+ if (codexHomeGap.catalogMetadataInvalid) parts.push('invalid catalog metadata');
730
978
  const deferred = !codexSessionActive;
731
979
  const deferredNote = deferred
732
980
  ? ' Deferred unless you use Codex — not a blocker for Grok/Claude/Cursor. '
@@ -735,7 +983,9 @@ export function printSkillAndCodexGapHints(root, opts) {
735
983
  `Codex home skill catalog (${codexSkillsDir()}) behind this Ark (${parts.join(', ')}).` +
736
984
  deferredNote +
737
985
  `Catalog is $CODEX_HOME/skills/<name>/SKILL.md (not flat prompts). ` +
738
- `When using Codex: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --codex-home --force')}`;
986
+ (codexHomeGap.catalogMetadataInvalid
987
+ ? 'Inspect the shared catalog metadata before retrying; invalid metadata fails safe.'
988
+ : `When using Codex: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --codex-home --force')}`);
739
989
  console.log(deferred ? color.dim(msg) : color.yellow(msg));
740
990
  }
741
991
  if (codexRepoSkillGap && codexSessionActive) {