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
@@ -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) {