arkgate 4.7.0 → 4.7.2

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 (55) hide show
  1. package/CHANGELOG.md +53 -2
  2. package/README.md +32 -23
  3. package/bin/lib/agent-projection.mjs +1 -1
  4. package/bin/lib/agent-skills-package.mjs +29 -0
  5. package/bin/lib/check-args.mjs +1 -0
  6. package/bin/lib/ci-and-commands.mjs +3 -3
  7. package/bin/lib/doctor-next-actions.mjs +5 -1
  8. package/bin/lib/enforcement-honesty.mjs +1 -1
  9. package/bin/lib/first-run-help.mjs +5 -5
  10. package/bin/lib/install-migrate.mjs +29 -57
  11. package/bin/lib/managed-upgrade.mjs +2 -7
  12. package/bin/lib/skill-catalog-apply.mjs +126 -0
  13. package/bin/lib/skill-install.mjs +264 -33
  14. package/bin/lib/skill-write.mjs +3 -0
  15. package/dist/index.cjs +25 -25
  16. package/dist/index.d.ts +19 -2
  17. package/dist/index.js +19 -19
  18. package/docs/README.md +8 -5
  19. package/docs/agent-guide.md +6 -5
  20. package/docs/ai-gates.md +4 -3
  21. package/docs/develop.md +4 -0
  22. package/docs/enthusiast/README.md +3 -0
  23. package/docs/enthusiast/how-to-agent-gates.md +1 -1
  24. package/docs/package-surface.md +6 -3
  25. package/docs/product-voice.md +118 -5
  26. package/docs/use.md +15 -8
  27. package/package.json +2 -2
  28. package/server.json +4 -4
  29. package/templates/agent-skills/README.md +1 -1
  30. package/templates/agent-skills/ark-adopt/SKILL.md +2 -2
  31. package/templates/agent-skills/ark-architect/SKILL.md +1 -1
  32. package/templates/agent-skills/ark-autopilot/SKILL.md +2 -2
  33. package/templates/agent-skills/ark-contract/SKILL.md +8 -7
  34. package/templates/agent-skills/ark-coverage/SKILL.md +1 -1
  35. package/templates/agent-skills/ark-explain/SKILL.md +1 -1
  36. package/templates/agent-skills/ark-explore/SKILL.md +1 -1
  37. package/templates/agent-skills/ark-fix/SKILL.md +1 -1
  38. package/templates/agent-skills/ark-loop/SKILL.md +1 -1
  39. package/templates/agent-skills/ark-place/SKILL.md +1 -1
  40. package/templates/agent-skills/ark-runtime/SKILL.md +1 -1
  41. package/templates/agent-skills/ark-think/SKILL.md +1 -1
  42. package/templates/agent-skills/ark-upgrade/SKILL.md +1 -1
  43. package/templates/skills/ark-adopt.md +2 -2
  44. package/templates/skills/ark-architect.md +1 -1
  45. package/templates/skills/ark-autopilot.md +2 -2
  46. package/templates/skills/ark-contract.md +8 -7
  47. package/templates/skills/ark-coverage.md +1 -1
  48. package/templates/skills/ark-explain.md +1 -1
  49. package/templates/skills/ark-explore.md +1 -1
  50. package/templates/skills/ark-fix.md +1 -1
  51. package/templates/skills/ark-loop.md +1 -1
  52. package/templates/skills/ark-place.md +1 -1
  53. package/templates/skills/ark-runtime.md +1 -1
  54. package/templates/skills/ark-think.md +1 -1
  55. package/templates/skills/ark-upgrade.md +1 -1
@@ -3,8 +3,14 @@
3
3
  */
4
4
  import { createHash } from 'node:crypto';
5
5
  import fs from 'node:fs';
6
+ import os from 'node:os';
6
7
  import path from 'node:path';
7
8
  import { arkCommand } from '../ark-shared.mjs';
9
+ import {
10
+ parseSkillDescriptionVersion,
11
+ stampSkillDescription,
12
+ stripSkillDescriptionVersion,
13
+ } from './agent-skills-package.mjs';
8
14
  import { codexPromptsDir, codexSkillsDir } from './codex-home.mjs';
9
15
  import { __packageRoot, isCompactRouterAgentsContent, readJson } from './gate-files.mjs';
10
16
 
@@ -194,22 +200,61 @@ export const KNOWN_TOOLS = [
194
200
  // Codex: discovers Agent Skills directories with SKILL.md — repo path is the
195
201
  // official `.agents/skills/<name>/SKILL.md` (not dead `.codex/prompts/*.md`).
196
202
  // Home install uses `$CODEX_HOME/skills/<name>/SKILL.md` via --codex-home.
203
+ /** Project-canonical Agent Skills catalog (Codex, Cursor, Antigravity all read this). */
204
+ export const SKILL_CANONICAL_DIR = '.agents/skills';
205
+
206
+ export function canonicalSkillPath(name) {
207
+ return `${SKILL_CANONICAL_DIR}/${name}/SKILL.md`;
208
+ }
209
+
210
+ /**
211
+ * Hosts that natively load `.agents/skills` — do not also copy bytes there under
212
+ * a second name. Cursor/Codex list every path they scan; two copies = two picker rows.
213
+ */
214
+ export const SKILL_NATIVE_AGENTS_HOSTS = Object.freeze(['codex', 'cursor', 'antigravity']);
215
+
216
+ /**
217
+ * Hosts that do not scan `.agents/skills`. Adapter is a relative symlink to the
218
+ * canonical catalog so Grok/Claude/OpenCode see the same bytes.
219
+ * Cursor also scans `.claude/skills` — doctor warns; still one body + visible version.
220
+ */
221
+ export const SKILL_ADAPTER_LINKS = Object.freeze({
222
+ claude: (name) => ({
223
+ link: `.claude/skills/${name}`,
224
+ target: `../../${SKILL_CANONICAL_DIR}/${name}`,
225
+ }),
226
+ grok: (name) => ({
227
+ link: `.grok/skills/${name}`,
228
+ target: `../../${SKILL_CANONICAL_DIR}/${name}`,
229
+ }),
230
+ opencode: (name) => ({
231
+ link: `.opencode/skills/${name}`,
232
+ target: `../../${SKILL_CANONICAL_DIR}/${name}`,
233
+ }),
234
+ });
235
+
197
236
  export const SKILL_TOOL_TARGETS = {
198
237
  claude: (name) => `.claude/skills/${name}/SKILL.md`,
199
- cursor: (name) => `.cursor/commands/${name}.md`,
238
+ // Cursor 2026 Agent Skills: `.agents/skills` (not a second `.cursor/commands` copy).
239
+ cursor: (name) => canonicalSkillPath(name),
200
240
  // Official Codex REPO skill scope (Agent Skills standard).
201
- codex: (name) => `.agents/skills/${name}/SKILL.md`,
202
- // Grok Build: project skills at .grok/skills/<name>/SKILL.md (slash-invocable).
241
+ codex: (name) => canonicalSkillPath(name),
203
242
  grok: (name) => `.grok/skills/${name}/SKILL.md`,
204
- // Antigravity loads Agent Skills from `.agents/skills` (shared path with Codex).
205
- antigravity: (name) => `.agents/skills/${name}/SKILL.md`,
206
- // OpenCode project skills under `.opencode/skills`.
243
+ antigravity: (name) => canonicalSkillPath(name),
207
244
  opencode: (name) => `.opencode/skills/${name}/SKILL.md`,
208
245
  windsurf: (name) => `.windsurf/workflows/${name}.md`,
209
246
  cline: (name) => `.clinerules/workflows/${name}.md`,
210
247
  copilot: (name) => `.github/prompts/${name}.prompt.md`,
211
248
  };
212
249
 
250
+ /** Hosts whose catalog is the project `.agents/skills` tree (write once). */
251
+ export function usesCanonicalSkillCatalog(tool) {
252
+ return (
253
+ SKILL_NATIVE_AGENTS_HOSTS.includes(tool) ||
254
+ Object.prototype.hasOwnProperty.call(SKILL_ADAPTER_LINKS, tool)
255
+ );
256
+ }
257
+
213
258
  // The version of the arkgate package these bins ship with. Used to
214
259
  // stamp installed skills so a normal ark-check can tell "outdated skill from an
215
260
  // older Ark" apart from "user-customized skill" — the stamp moves with the
@@ -223,8 +268,43 @@ export function arkPackageVersion() {
223
268
  }
224
269
  }
225
270
 
226
- // Insert `arkVersion: <v>` into a skill's YAML frontmatter (before its closing
227
- // `---`). No frontmatter returned unchanged. Idempotent for a given version
271
+ /**
272
+ * Rewrite a YAML `description:` line with a visible `arkgate@<version>. ` prefix.
273
+ * Preserves quoting. Hosts show this string in the skill picker (unlike arkVersion).
274
+ */
275
+ function stampDescriptionYamlLine(line, version) {
276
+ const match = String(line).match(/^(description:\s*)(.*)$/);
277
+ if (!match) return line;
278
+ let raw = match[2] ?? '';
279
+ const quoted =
280
+ (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) ||
281
+ (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2);
282
+ const quote = quoted ? raw[0] : '';
283
+ const value = quoted ? raw.slice(1, -1) : raw;
284
+ const stamped = stampSkillDescription(value, version);
285
+ if (!quoted) return `${match[1]}${stamped}`;
286
+ const escaped = stamped.replaceAll('\\', '\\\\').replaceAll(quote, `\\${quote}`);
287
+ return `${match[1]}${quote}${escaped}${quote}`;
288
+ }
289
+
290
+ function managedDescriptionYamlLine(line) {
291
+ const match = String(line).match(/^(description:\s*)(.*)$/);
292
+ if (!match) return line;
293
+ let raw = match[2] ?? '';
294
+ const quoted =
295
+ (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) ||
296
+ (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2);
297
+ const quote = quoted ? raw[0] : '';
298
+ const value = quoted ? raw.slice(1, -1) : raw;
299
+ const stripped = stripSkillDescriptionVersion(value);
300
+ if (stripped === value) return line;
301
+ if (!quoted) return `${match[1]}${stripped}`;
302
+ const escaped = stripped.replaceAll('\\', '\\\\').replaceAll(quote, `\\${quote}`);
303
+ return `${match[1]}${quote}${escaped}${quote}`;
304
+ }
305
+
306
+ // Insert `arkVersion: <v>` and a visible `arkgate@<v>. ` description prefix.
307
+ // No frontmatter → returned unchanged. Idempotent for a given version
228
308
  // and preserves the checked-out line ending on Windows.
229
309
  export function stampSkill(content, version) {
230
310
  if (!version) return content;
@@ -241,6 +321,12 @@ export function stampSkill(content, version) {
241
321
  } else {
242
322
  lines.splice(closeIdx, 0, `arkVersion: ${version}`);
243
323
  }
324
+ const descIdx = lines.findIndex(
325
+ (line, i) => i > 0 && i < lines.indexOf('---', 1) && /^description:\s*/.test(line)
326
+ );
327
+ if (descIdx !== -1) {
328
+ lines[descIdx] = stampDescriptionYamlLine(lines[descIdx], version);
329
+ }
244
330
  return lines.join(newline);
245
331
  }
246
332
 
@@ -336,6 +422,9 @@ export function skillContentIdentity(content) {
336
422
  if (end >= 0) {
337
423
  for (let index = 1; index < end; index += 1) {
338
424
  if (/^arkVersion:/.test(lines[index])) lines[index] = 'arkVersion:<managed>';
425
+ else if (/^description:\s*/.test(lines[index])) {
426
+ lines[index] = managedDescriptionYamlLine(lines[index]);
427
+ }
339
428
  }
340
429
  text = lines.join('\n');
341
430
  }
@@ -379,7 +468,7 @@ export function skillContentMatchesTemplate(installedContent, templateContent) {
379
468
  * }} input
380
469
  * @returns {{
381
470
  * action: 'write'|'skip',
382
- * reason: 'missing'|'content-current'|'newer-home-version'|'unknown-source-version'|'existing-preserved'|'content-update',
471
+ * reason: 'missing'|'content-current'|'stamp-refresh'|'newer-home-version'|'unknown-source-version'|'existing-preserved'|'content-update',
383
472
  * scope: 'repo'|'home',
384
473
  * sourceVersion: string|null,
385
474
  * installedVersion: string|null,
@@ -405,13 +494,9 @@ export function planSkillInstall(input) {
405
494
  });
406
495
 
407
496
  if (existingContent === null) return result('write', 'missing');
408
- if (
409
- existingContent === targetContent ||
410
- skillContentIdentity(existingContent) === skillContentIdentity(targetContent)
411
- ) {
497
+ if (existingContent === targetContent) {
412
498
  return result('skip', 'content-current');
413
499
  }
414
-
415
500
  if (scope === 'home') {
416
501
  if (installedVersion && !sourceVersion) {
417
502
  return result('skip', 'unknown-source-version', true, true);
@@ -424,6 +509,11 @@ export function planSkillInstall(input) {
424
509
  return result('skip', 'newer-home-version', true, true);
425
510
  }
426
511
  }
512
+ if (skillContentIdentity(existingContent) === skillContentIdentity(targetContent)) {
513
+ // Body matches; only arkVersion / visible description prefix drifted.
514
+ // Refresh the stamp without --force so the picker shows arkgate@this-package.
515
+ return result('write', 'stamp-refresh');
516
+ }
427
517
 
428
518
  if (!input.force) return result('skip', 'existing-preserved', true);
429
519
  return result('write', 'content-update');
@@ -467,6 +557,113 @@ export function skillTemplates() {
467
557
  .map((name) => [path.basename(name, '.md'), fs.readFileSync(path.join(dir, name), 'utf8')]);
468
558
  }
469
559
 
560
+ /**
561
+ * Point a host-native skills dir at the project canonical catalog.
562
+ * Relative symlink so clones keep working. Fallback copy when the OS refuses links.
563
+ * @returns {'linked'|'copied'|'current'|'skipped-customized'|'missing-canonical'}
564
+ */
565
+ export function ensureSkillAdapterLink(root, name, adapter, force = false) {
566
+ const canonicalDir = path.join(root, SKILL_CANONICAL_DIR, name);
567
+ const canonicalFile = path.join(canonicalDir, 'SKILL.md');
568
+ if (!fs.existsSync(canonicalFile)) return 'missing-canonical';
569
+ const linkPath = path.join(root, adapter.link);
570
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
571
+ const existing = fs.lstatSync(linkPath, { throwIfNoEntry: false });
572
+ if (existing?.isSymbolicLink()) {
573
+ const current = fs.readlinkSync(linkPath).replaceAll('\\', '/');
574
+ if (current === adapter.target) return 'current';
575
+ fs.unlinkSync(linkPath);
576
+ } else if (existing) {
577
+ const adapterFile = path.join(linkPath, 'SKILL.md');
578
+ let adapterContent = null;
579
+ try {
580
+ adapterContent = fs.readFileSync(adapterFile, 'utf8');
581
+ } catch {
582
+ adapterContent = null;
583
+ }
584
+ const canonicalContent = fs.readFileSync(canonicalFile, 'utf8');
585
+ if (
586
+ !force &&
587
+ adapterContent &&
588
+ skillContentIdentity(adapterContent) !== skillContentIdentity(canonicalContent)
589
+ ) {
590
+ return 'skipped-customized';
591
+ }
592
+ fs.rmSync(linkPath, { recursive: true, force: true });
593
+ }
594
+ try {
595
+ fs.symlinkSync(adapter.target, linkPath);
596
+ return 'linked';
597
+ } catch {
598
+ fs.cpSync(canonicalDir, linkPath, { recursive: true });
599
+ return 'copied';
600
+ }
601
+ }
602
+
603
+ export function linkSkillHostAdapters(root, tools, skillNames, force = false) {
604
+ const results = [];
605
+ for (const tool of tools) {
606
+ const adapterFor = SKILL_ADAPTER_LINKS[tool];
607
+ if (!adapterFor) continue;
608
+ for (const name of skillNames) {
609
+ const adapter = adapterFor(name);
610
+ results.push({
611
+ tool,
612
+ name,
613
+ status: ensureSkillAdapterLink(root, name, adapter, force),
614
+ relativePath: `${adapter.link}/SKILL.md`,
615
+ });
616
+ }
617
+ }
618
+ return results;
619
+ }
620
+
621
+ const HOME_ARK_SKILL_ROOTS = [
622
+ () => path.join(codexSkillsDir()),
623
+ () => path.join(os.homedir(), '.claude', 'skills'),
624
+ () => path.join(os.homedir(), '.grok', 'skills'),
625
+ ];
626
+
627
+ function projectHasCanonicalCatalog(root, skillNames) {
628
+ return skillNames.some((name) =>
629
+ fs.existsSync(path.join(root, canonicalSkillPath(name)))
630
+ );
631
+ }
632
+
633
+ /**
634
+ * Remove frozen `/ark-*` directories from agent home catalogs when the project
635
+ * already has `.agents/skills`. Codex/Cursor list user+repo; same name twice.
636
+ * Never deletes non-Ark skills.
637
+ */
638
+ export function pruneHomeArkSkillDuplicates(root, skillNames = skillTemplateNames()) {
639
+ const names = skillNames.length ? skillNames : skillTemplateNames();
640
+ const removed = [];
641
+ if (!projectHasCanonicalCatalog(root, names)) {
642
+ return { ok: false, reason: 'no-project-catalog', removed };
643
+ }
644
+ for (const dirFn of HOME_ARK_SKILL_ROOTS) {
645
+ const dir = dirFn();
646
+ for (const name of names) {
647
+ const skillDir = path.join(dir, name);
648
+ const stat = fs.lstatSync(skillDir, { throwIfNoEntry: false });
649
+ if (!stat) continue;
650
+ fs.rmSync(skillDir, { recursive: true, force: true });
651
+ removed.push(skillDir);
652
+ }
653
+ const catalog = path.join(dir, '.arkgate-catalog.json');
654
+ const pending = path.join(dir, '.arkgate-catalog.pending.json');
655
+ for (const meta of [catalog, pending]) {
656
+ if (fs.existsSync(meta)) {
657
+ fs.rmSync(meta, { force: true });
658
+ removed.push(meta);
659
+ }
660
+ }
661
+ }
662
+ return { ok: true, reason: 'pruned', removed };
663
+ }
664
+
665
+ export { parseSkillDescriptionVersion, stripSkillDescriptionVersion };
666
+
470
667
  // Skill names only, silent on a missing templates dir — for the freshness
471
668
  // advisory below, which must not print packaging warnings on every check run.
472
669
  export function skillTemplateNames() {
@@ -732,8 +929,30 @@ export function assessCodexSkillParity(root) {
732
929
  // signal, so it is not auto-detected (explicit --tools only), matching resolveTools.
733
930
  export function detectCodexHomeGap(root) {
734
931
  const parity = assessCodexSkillParity(root);
735
- if (!parity || !parity.homeNeedsAttention) return null;
736
- const { home, packageVersion, expectedCount, skillsDir } = parity;
932
+ if (!parity) return null;
933
+ const { home, packageVersion, expectedCount, skillsDir, repo } = parity;
934
+ const repoComplete =
935
+ Boolean(repo?.inPlay) && repo.missing === 0 && !repo.legacyPromptsOnly;
936
+ const homePresent = Boolean(home?.inPlay) && home.presentCount > 0;
937
+ if (repoComplete && homePresent) {
938
+ return {
939
+ missing: 0,
940
+ stale: 0,
941
+ legacyPromptsOnly: false,
942
+ hasLegacyPrompts: Boolean(home.hasLegacyPrompts),
943
+ presentCount: home.presentCount,
944
+ expectedCount,
945
+ packageVersion,
946
+ skillsDir,
947
+ catalogVersion: home.catalogVersion,
948
+ pendingRecoveryRequired: false,
949
+ catalogMetadataInvalid: false,
950
+ catalogStateReason: null,
951
+ preferProject: true,
952
+ duplicateHome: true,
953
+ };
954
+ }
955
+ if (!parity.homeNeedsAttention) return null;
737
956
  return {
738
957
  missing: home.missing,
739
958
  stale: home.stale,
@@ -751,6 +970,8 @@ export function detectCodexHomeGap(root) {
751
970
  : home.pendingRecoveryRequired
752
971
  ? 'interrupted catalog commit'
753
972
  : null,
973
+ preferProject: false,
974
+ duplicateHome: false,
754
975
  };
755
976
  }
756
977
 
@@ -971,24 +1192,34 @@ export function printSkillAndCodexGapHints(root, opts) {
971
1192
  }
972
1193
  }
973
1194
  if (codexHomeGap) {
974
- const parts = [];
975
- if (codexHomeGap.legacyPromptsOnly) parts.push('legacy-prompts-only');
976
- if (codexHomeGap.missing > 0) parts.push(`${codexHomeGap.missing} missing`);
977
- if (codexHomeGap.stale > 0) parts.push(`${codexHomeGap.stale} content-behind-package`);
978
- if (codexHomeGap.pendingRecoveryRequired) parts.push('interrupted catalog commit');
979
- if (codexHomeGap.catalogMetadataInvalid) parts.push('invalid catalog metadata');
980
1195
  const deferred = !codexSessionActive;
981
- const deferredNote = deferred
982
- ? ' Deferred unless you use Codex — not a blocker for Grok/Claude/Cursor. '
983
- : ' ';
984
- const msg =
985
- `Codex home skill catalog (${codexSkillsDir()}) behind this Ark (${parts.join(', ')}).` +
986
- deferredNote +
987
- `Catalog is $CODEX_HOME/skills/<name>/SKILL.md (not flat prompts). ` +
988
- (codexHomeGap.catalogMetadataInvalid
989
- ? 'Inspect the shared catalog metadata before retrying; invalid metadata fails safe.'
990
- : `When using Codex: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --codex-home --force')}`);
991
- console.log(deferred ? color.dim(msg) : color.yellow(msg));
1196
+ if (codexHomeGap.duplicateHome) {
1197
+ const msg =
1198
+ `Codex home $CODEX_HOME/skills/ark-* duplicates project .agents/skills (picker shows two copies). ` +
1199
+ (deferred
1200
+ ? 'Deferred unless you use Codex. '
1201
+ : '') +
1202
+ `Remove home copies: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --prune-home-duplicates')}`;
1203
+ console.log(deferred ? color.dim(msg) : color.yellow(msg));
1204
+ } else {
1205
+ const parts = [];
1206
+ if (codexHomeGap.legacyPromptsOnly) parts.push('legacy-prompts-only');
1207
+ if (codexHomeGap.missing > 0) parts.push(`${codexHomeGap.missing} missing`);
1208
+ if (codexHomeGap.stale > 0) parts.push(`${codexHomeGap.stale} content-behind-package`);
1209
+ if (codexHomeGap.pendingRecoveryRequired) parts.push('interrupted catalog commit');
1210
+ if (codexHomeGap.catalogMetadataInvalid) parts.push('invalid catalog metadata');
1211
+ const deferredNote = deferred
1212
+ ? ' Deferred unless you use Codex — not a blocker for Grok/Claude/Cursor. '
1213
+ : ' ';
1214
+ const msg =
1215
+ `Codex home skill catalog (${codexSkillsDir()}) behind this Ark (${parts.join(', ')}).` +
1216
+ deferredNote +
1217
+ `Catalog is $CODEX_HOME/skills/<name>/SKILL.md (not flat prompts). ` +
1218
+ (codexHomeGap.catalogMetadataInvalid
1219
+ ? 'Inspect the shared catalog metadata before retrying; invalid metadata fails safe.'
1220
+ : `When using Codex: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --codex-home --force')}`);
1221
+ console.log(deferred ? color.dim(msg) : color.yellow(msg));
1222
+ }
992
1223
  }
993
1224
  if (codexRepoSkillGap && codexSessionActive) {
994
1225
  const parts = [];
@@ -393,6 +393,9 @@ export function skillInstallNote(plan) {
393
393
  if (plan.reason === 'content-current') {
394
394
  return `scope=${scope}; body current; installed=${installed}; source=${source}; no write`;
395
395
  }
396
+ if (plan.reason === 'stamp-refresh') {
397
+ return `scope=${scope}; stamp refresh; installed=${installed}; source=${source}`;
398
+ }
396
399
  if (plan.reason === 'newer-home-version') {
397
400
  return `scope=${scope}; CONFLICT installed=${installed} newer than source=${source}; downgrade blocked`;
398
401
  }