nexakit 0.1.0 → 0.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 (113) hide show
  1. package/.claude-plugin/marketplace.json +18 -1
  2. package/README.md +40 -30
  3. package/assets/brand/README.md +0 -2
  4. package/assets/runtime/package-lock.json +2 -2
  5. package/bin/agent-task.js +12 -10
  6. package/bin/agent-toolkit-gemini.js +11 -9
  7. package/bin/agent-toolkit-inbox.js +12 -10
  8. package/bin/agent-toolkit-mcp.js +14 -8
  9. package/bin/agent-toolkit.js +12 -9
  10. package/bin/nexakit.js +12 -9
  11. package/bin/project-memory-mcp.js +12 -9
  12. package/bin/project-memory.js +5 -3
  13. package/{skills/depurar → catalogs/skills/vercel-composition-patterns}/LICENSE.txt +6 -6
  14. package/catalogs/skills/vercel-react-best-practices/LICENSE.txt +27 -0
  15. package/catalogs/skills.json +70 -35
  16. package/docs/skills/evidencias/2026-10-28-recomendador-preparacion.md +1 -1
  17. package/package.json +5 -3
  18. package/plugins/nexakit-modelo/.claude-plugin/plugin.json +1 -0
  19. package/plugins/nexakit-modelo/hooks/hooks.json +1 -0
  20. package/plugins/nexakit-modelo/hooks/register.js +48 -0
  21. package/plugins/nexakit-modelo/hooks/status.js +8 -0
  22. package/plugins/nexakit-modelo/tests/register.test.ts +21 -0
  23. package/plugins/nexakit-panel/.claude-plugin/plugin.json +1 -0
  24. package/plugins/nexakit-panel/README.md +20 -0
  25. package/plugins/nexakit-panel/hooks/hooks.json +1 -0
  26. package/plugins/nexakit-panel/hooks/lienzo.js +382 -0
  27. package/plugins/nexakit-panel/hooks/panel.js +279 -0
  28. package/plugins/nexakit-panel/hooks/register.tsx +248 -0
  29. package/plugins/nexakit-panel/tests/lienzo.test.ts +142 -0
  30. package/plugins/nexakit-panel/tests/panel.test.ts +240 -0
  31. package/plugins/nexakit-panel/tests/register.test.ts +167 -0
  32. package/plugins/nexakit-panel/types/index.d.ts +41 -0
  33. package/plugins/nexakit-sesion/.claude-plugin/plugin.json +1 -0
  34. package/plugins/nexakit-sesion/README.md +12 -0
  35. package/plugins/nexakit-sesion/hooks/aviso.js +38 -0
  36. package/plugins/nexakit-sesion/hooks/hooks.json +1 -0
  37. package/plugins/nexakit-sesion/hooks/register.js +28 -0
  38. package/plugins/nexakit-sesion/tests/register.test.ts +96 -0
  39. package/schemas/skill-catalog.schema.json +9 -2
  40. package/skills/cerrar-memoria/SKILL.md +6 -2
  41. package/skills/cerrar-memoria/references/cierre.md +5 -1
  42. package/skills/elegir-modelo/SKILL.md +68 -0
  43. package/skills/elegir-modelo/modelos.mjs +108 -0
  44. package/skills/gemini/SKILL.md +1 -1
  45. package/skills/project-handoff/SKILL.md +39 -12
  46. package/skills/project-handoff/references/handoff-template.md +24 -17
  47. package/skills/quality-gate/SKILL.md +6 -12
  48. package/skills/revisar/SKILL.md +6 -16
  49. package/skills/{desarrollo-con-pruebas/LICENSE.txt → revisar-skill/LICENSE-find-skills.txt} +5 -5
  50. package/skills/revisar-skill/SKILL.md +94 -0
  51. package/skills/revision-cruzada/SKILL.md +53 -475
  52. package/skills/web-design-guidelines/AGENTS.md +156 -0
  53. package/skills/{spec-driven-development → web-design-guidelines}/LICENSE.txt +1 -6
  54. package/skills/web-design-guidelines/SKILL.md +20 -30
  55. package/src/agent-models.js +53 -13
  56. package/src/brand.js +117 -0
  57. package/src/cli-executable.js +5 -3
  58. package/src/cli.js +16 -9
  59. package/src/connect.js +56 -4
  60. package/src/init.js +9 -1
  61. package/src/install-lock.js +127 -0
  62. package/src/install-registry.js +61 -0
  63. package/src/legacy-repair.js +71 -0
  64. package/src/link-states.js +172 -0
  65. package/src/memory-locator.js +118 -0
  66. package/src/missing-dependencies.js +21 -0
  67. package/src/model-command.js +147 -0
  68. package/src/panel-cli.js +182 -0
  69. package/src/project-root.js +41 -0
  70. package/src/project-signals.js +339 -0
  71. package/src/recommend-report.js +77 -0
  72. package/src/review-instructions.js +211 -0
  73. package/src/review-policy.js +35 -0
  74. package/src/revision-adapters.js +157 -0
  75. package/src/revision-context.js +86 -0
  76. package/src/revision-isolation.js +222 -0
  77. package/src/revision-process.js +103 -0
  78. package/src/revision-provenance.js +89 -0
  79. package/src/revision-snapshot.js +120 -0
  80. package/src/revision.js +242 -0
  81. package/src/setup-apply.js +231 -0
  82. package/src/setup-plan.js +231 -0
  83. package/src/setup-state.js +277 -0
  84. package/src/setup-tui.js +555 -0
  85. package/src/skill-catalog-add.js +91 -0
  86. package/src/skill-catalog-audit.js +26 -27
  87. package/src/skill-files.js +39 -0
  88. package/src/skill-recommendations.js +321 -208
  89. package/src/skill-scan-report.js +14 -0
  90. package/src/skill-scan.js +178 -0
  91. package/src/stable-command.js +53 -0
  92. package/src/stable-install.js +222 -0
  93. package/src/superpowers.js +133 -0
  94. package/src/sync-apply.js +8 -5
  95. package/src/sync-files.js +2 -1
  96. package/src/terminal-text.js +68 -0
  97. package/src/toolkit-cli.js +397 -70
  98. package/src/toolkit-commands-guide.js +53 -39
  99. package/src/toolkit-setup.js +507 -110
  100. package/assets/brand/agent-toolkit-mark-v1.png +0 -0
  101. package/skills/depurar/SKILL.md +0 -69
  102. package/skills/depurar/agents/openai.yaml +0 -7
  103. package/skills/depurar/references/tecnicas-de-diagnostico.md +0 -37
  104. package/skills/desarrollo-con-pruebas/SKILL.md +0 -55
  105. package/skills/desarrollo-con-pruebas/agents/openai.yaml +0 -7
  106. package/skills/desarrollo-con-pruebas/references/buenas-pruebas.md +0 -33
  107. package/skills/estado/SKILL.md +0 -41
  108. package/skills/programar/SKILL.md +0 -19
  109. package/skills/spec-driven-development/SKILL.md +0 -48
  110. package/skills/spec-driven-development/agents/openai.yaml +0 -7
  111. package/skills/spec-driven-development/references/spec-template.md +0 -55
  112. package/skills/spec-driven-development/references/workflow.md +0 -70
  113. package/src/toolkit-tui.js +0 -961
@@ -1,10 +1,17 @@
1
+ import { ownedReviewInstructions, removeReviewInstructions } from './review-instructions.js';
1
2
  import { createHash } from 'node:crypto';
2
3
  import fs from 'node:fs/promises';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
6
+ import { acquireLock } from './install-lock.js';
7
+ import { isRegistered, readRegistry, recordEntries } from './install-registry.js';
8
+ import { classifyLink, relinkIfUnchanged } from './link-states.js';
5
9
  import { IS_WINDOWS } from './platform.js';
10
+ import { detectSignals, SIGNAL_LABELS } from './project-signals.js';
11
+ import { resolveProjectRoot } from './project-root.js';
6
12
  import { loadSkillCatalog } from './skill-catalog.js';
7
- import { TOOLKIT_ROOT } from './toolkit-setup.js';
13
+ import { actualRoot, ensureStableInstall, nexakitDir } from './stable-install.js';
14
+ import { TOOLKIT_ROOT, globalSkillTarget, resolveRoots } from './toolkit-setup.js';
8
15
 
9
16
  const MAX_MANIFEST_BYTES = 512 * 1024;
10
17
  const MAX_SKILL_FILES = 256;
@@ -13,6 +20,14 @@ const VALID_CLIENTS = new Set(['codex', 'claude']);
13
20
  const VALID_INSTALL_MODES = new Set(['symlink', 'copy']);
14
21
  const VALID_GIT_IGNORE = new Set(['gitignore', 'git-exclude', 'none']);
15
22
  const SKILL_NAME_RE = /^[a-z0-9-]+$/u;
23
+ // Antigravity reads project skills from `.agents/skills`, the folder Codex uses: one link serves both.
24
+ const projectClientsFor = agent => (agent === 'all' ? ['codex', 'claude'] : [agent === 'antigravity' ? 'codex' : agent]);
25
+ const servesFor = (client, agent) => (client === 'claude' ? ['claude'] : agent === 'all' ? ['codex', 'antigravity'] : [agent === 'antigravity' ? 'antigravity' : 'codex']);
26
+ // The coarse vocabulary the rest of the code reads; `linkState` keeps the detailed one.
27
+ const COARSE_STATE = Object.freeze({
28
+ falta: 'missing', efimero: 'replace', 'roto-propio': 'replace', instalado: 'installed',
29
+ 'otra-copia': 'installed', conflicto: 'conflict', 'roto-ajeno': 'conflict'
30
+ });
16
31
 
17
32
  export const PROJECT_TOOLKIT_DIR = '.agent-toolkit';
18
33
  const PROJECT_MANIFEST_FILE = 'manifest.json';
@@ -22,75 +37,7 @@ const IGNORE_BLOCK_END = '# <<< agent-toolkit (managed) <<<';
22
37
  const LEVEL_AGENT_HEADER = '# Generado por `agent-toolkit-inbox agentes`.';
23
38
  const LEVEL_AGENT_NAMES = ['nivel_ligero.toml', 'nivel_normal.toml', 'nivel_exigente.toml', 'nivel_alto.toml'];
24
39
 
25
- const SIGNAL_LABELS = Object.freeze({
26
- nodejs: 'Node.js',
27
- typescript: 'TypeScript',
28
- 'mcp-sdk': 'MCP SDK',
29
- react: 'React',
30
- nextjs: 'Next.js',
31
- tailwindcss: 'Tailwind CSS',
32
- astro: 'Astro',
33
- vue: 'Vue',
34
- svelte: 'Svelte',
35
- supabase: 'Supabase',
36
- postgres: 'PostgreSQL',
37
- express: 'Express',
38
- fastify: 'Fastify',
39
- hono: 'Hono',
40
- playwright: 'Playwright',
41
- vitest: 'Vitest'
42
- });
43
-
44
- const PACKAGE_TO_SIGNAL = Object.freeze({
45
- '@modelcontextprotocol/sdk': 'mcp-sdk',
46
- '@modelcontextprotocol/server': 'mcp-sdk',
47
- '@modelcontextprotocol/client': 'mcp-sdk',
48
- fastmcp: 'mcp-sdk',
49
- next: 'nextjs',
50
- react: 'react',
51
- 'react-dom': 'react',
52
- tailwindcss: 'tailwindcss',
53
- '@tailwindcss/postcss': 'tailwindcss',
54
- '@tailwindcss/vite': 'tailwindcss',
55
- typescript: 'typescript',
56
- astro: 'astro',
57
- vue: 'vue',
58
- nuxt: 'vue',
59
- svelte: 'svelte',
60
- '@sveltejs/kit': 'svelte',
61
- '@supabase/supabase-js': 'supabase',
62
- '@supabase/ssr': 'supabase',
63
- pg: 'postgres',
64
- postgres: 'postgres',
65
- '@neondatabase/serverless': 'postgres',
66
- 'drizzle-orm': 'postgres',
67
- '@prisma/client': 'postgres',
68
- express: 'express',
69
- fastify: 'fastify',
70
- hono: 'hono',
71
- '@hono/node-server': 'hono',
72
- playwright: 'playwright',
73
- '@playwright/test': 'playwright',
74
- vitest: 'vitest'
75
- });
76
-
77
- const FRAMEWORK_CONFIG_HINTS = Object.freeze([
78
- ['next.config.js', 'nextjs'],
79
- ['next.config.mjs', 'nextjs'],
80
- ['next.config.ts', 'nextjs'],
81
- ['tailwind.config.js', 'tailwindcss'],
82
- ['tailwind.config.ts', 'tailwindcss'],
83
- ['astro.config.mjs', 'astro'],
84
- ['svelte.config.js', 'svelte']
85
- ]);
86
-
87
- const OUT_OF_COVERAGE_MANIFESTS = Object.freeze([
88
- 'Cargo.toml',
89
- 'pyproject.toml',
90
- 'go.mod',
91
- 'composer.json',
92
- 'Gemfile'
93
- ]);
40
+ export { SIGNAL_LABELS };
94
41
 
95
42
  const normalizedPath = value => (IS_WINDOWS ? value.toLowerCase() : value);
96
43
  const lstatOrNull = target => fs.lstat(target).catch(err => {
@@ -234,84 +181,36 @@ async function inspectProjectSkills(projectRoot, toolkitRoot, catalogByName, cli
234
181
  }));
235
182
  }
236
183
 
237
- export async function inspectSkillProject(projectRoot, { client, toolkitRoot = TOOLKIT_ROOT, catalog } = {}) {
184
+ async function rootPackageName(root) {
185
+ const info = await lstatOrNull(path.join(root, 'package.json'));
186
+ if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_MANIFEST_BYTES) return null;
187
+ try {
188
+ const name = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'))?.name;
189
+ return typeof name === 'string' && name.trim() ? name.trim() : null;
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ export async function inspectSkillProject(projectRoot, { client, toolkitRoot = TOOLKIT_ROOT, catalog, homeDirectory } = {}) {
238
196
  if (client !== undefined && !VALID_CLIENTS.has(client)) {
239
197
  throw new Error('El filtro --client solo admite codex o claude.');
240
198
  }
241
- const root = await fs.realpath(projectRoot);
199
+ // The project is the whole Git repository, even when nexakit runs from one of its subfolders.
200
+ const project = await resolveProjectRoot(projectRoot, homeDirectory ? { homeDirectory } : {});
201
+ const root = project.root;
242
202
  const resolvedToolkit = await fs.realpath(toolkitRoot);
243
203
  const activeCatalog = catalog ?? await loadSkillCatalog();
244
204
  const catalogByName = new Map(activeCatalog.skills.map(s => [s.name, s]));
245
-
246
- const signalSources = new Map();
247
- const addSignal = (id, source) => {
248
- const list = signalSources.get(id) ?? [];
249
- if (!list.includes(source)) list.push(source);
250
- signalSources.set(id, list);
251
- };
252
-
253
- const limits = [];
254
- let projectName = path.basename(root);
255
-
256
- const pkgPath = path.join(root, 'package.json');
257
- const pkgInfo = await lstatOrNull(pkgPath);
258
- if (pkgInfo) {
259
- if (!pkgInfo.isFile() || pkgInfo.isSymbolicLink() || pkgInfo.size > MAX_MANIFEST_BYTES) {
260
- limits.push('package.json excede el límite o no es un archivo regular; no se inspeccionó.');
261
- } else {
262
- let pkg = null;
263
- try {
264
- pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'));
265
- } catch {
266
- limits.push('package.json no contiene JSON válido; no se dedujeron dependencias Node.');
267
- }
268
- if (pkg && typeof pkg === 'object' && !Array.isArray(pkg)) {
269
- if (typeof pkg.name === 'string' && pkg.name.trim()) projectName = pkg.name.trim();
270
- addSignal('nodejs', 'package.json');
271
- for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
272
- const deps = pkg[field];
273
- if (!deps || typeof deps !== 'object' || Array.isArray(deps)) continue;
274
- for (const depName of Object.keys(deps)) {
275
- const mapped = PACKAGE_TO_SIGNAL[depName];
276
- if (mapped) addSignal(mapped, `package.json (${depName})`);
277
- }
278
- }
279
- }
280
- }
281
- }
282
-
283
- const tsconfigInfo = await lstatOrNull(path.join(root, 'tsconfig.json'));
284
- if (tsconfigInfo?.isFile() && !tsconfigInfo.isSymbolicLink()) {
285
- addSignal('typescript', 'tsconfig.json');
286
- }
287
-
288
- for (const [configFile, expectedSignal] of FRAMEWORK_CONFIG_HINTS) {
289
- const info = await lstatOrNull(path.join(root, configFile));
290
- if (info?.isFile() && !info.isSymbolicLink() && !signalSources.has(expectedSignal)) {
291
- limits.push(`Configuración observada sin dependencia confirmada en package.json: ${configFile}.`);
292
- }
293
- }
294
-
295
- for (const foreignManifest of OUT_OF_COVERAGE_MANIFESTS) {
296
- const info = await lstatOrNull(path.join(root, foreignManifest));
297
- if (info && !info.isSymbolicLink()) {
298
- limits.push(`Stack fuera de cobertura automática observada: ${foreignManifest}.`);
299
- }
300
- }
301
-
302
- if (!pkgInfo && signalSources.size === 0 && limits.length === 0) {
303
- limits.push('No se detectó package.json en la raíz; cobertura automática inicial enfocada en Node.js.');
304
- }
305
-
306
- const signals = [...signalSources.entries()]
307
- .map(([id, sources]) => ({ id, label: SIGNAL_LABELS[id] ?? id, sources }))
308
- .sort((a, b) => a.id.localeCompare(b.id));
205
+ const { signals, limits } = await detectSignals(root, { git: project.git });
206
+ const projectName = await rootPackageName(root) ?? project.displayName;
309
207
 
310
208
  const installed = await inspectProjectSkills(root, resolvedToolkit, catalogByName, client);
311
209
  const coupling = await inspectProjectCoupling(root, resolvedToolkit);
312
210
 
313
211
  return {
314
212
  projectRoot: root,
213
+ project,
315
214
  projectName,
316
215
  signals,
317
216
  installed,
@@ -595,6 +494,7 @@ export function recommendSkills({ catalog, inspection, profile, client, clients
595
494
  revision: skill.revision,
596
495
  installedPath: skill.installedPath,
597
496
  reasons,
497
+ matchedSignals,
598
498
  overlapsWith: [...skill.overlapsWith],
599
499
  reviewStatus: skill.review.status,
600
500
  reviewNotes: skill.review.notes,
@@ -627,6 +527,10 @@ export function recommendSkills({ catalog, inspection, profile, client, clients
627
527
  }
628
528
 
629
529
  optional.sort((a, b) => a.name.localeCompare(b.name));
530
+ // The skills that match more of what was detected come first; ties by name.
531
+ const byMatches = (a, b) => b.matchedSignals.length - a.matchedSignals.length || a.name.localeCompare(b.name);
532
+ recommended.sort(byMatches);
533
+ candidates.sort(byMatches);
630
534
 
631
535
  const externalCandidates = [];
632
536
  for (const ref of catalog.externalReferences) {
@@ -701,8 +605,8 @@ async function projectTargetState(source, target, mode, catalogEntry) {
701
605
  throw err;
702
606
  }
703
607
  }
704
- // mode === 'copy'
705
- if (info.isSymbolicLink()) return 'replace';
608
+ // mode === 'copy': links are classified by the planner; here one is never assumed replaceable.
609
+ if (info.isSymbolicLink()) return 'conflict';
706
610
  if (info.isDirectory() && catalogEntry) {
707
611
  const integrity = await verifyCatalogIntegrity(target, catalogEntry);
708
612
  return integrity === 'verified' ? 'installed' : 'conflict';
@@ -717,10 +621,12 @@ export async function planProjectSkillInstall({
717
621
  mode = 'symlink',
718
622
  gitIgnore = 'gitignore',
719
623
  catalog,
720
- toolkitRoot = TOOLKIT_ROOT
624
+ toolkitRoot = TOOLKIT_ROOT,
625
+ linkRoot,
626
+ homeDirectory = os.homedir()
721
627
  } = {}) {
722
- if (!['codex', 'claude', 'all'].includes(agent)) {
723
- throw new Error('El agente debe ser codex, claude o all.');
628
+ if (!['codex', 'claude', 'antigravity', 'all'].includes(agent)) {
629
+ throw new Error('El agente debe ser codex, claude, antigravity o all.');
724
630
  }
725
631
  if (!VALID_INSTALL_MODES.has(mode)) {
726
632
  throw new Error('El modo de instalación debe ser symlink o copy.');
@@ -728,13 +634,33 @@ export async function planProjectSkillInstall({
728
634
  if (!VALID_GIT_IGNORE.has(gitIgnore)) {
729
635
  throw new Error('La estrategia gitIgnore debe ser gitignore, git-exclude o none.');
730
636
  }
731
- const clients = agent === 'all' ? ['codex', 'claude'] : [agent];
637
+ const clients = projectClientsFor(agent);
732
638
  const root = await fs.realpath(projectRoot);
733
639
  const resolvedToolkit = await fs.realpath(toolkitRoot);
640
+ const home = await fs.realpath(homeDirectory).catch(() => path.resolve(homeDirectory));
641
+ const roots = await resolveRoots({ toolkitRoot: resolvedToolkit, homeDirectory: home });
642
+ // An explicit linkRoot means the caller chose where to link, so no stable copy is made.
643
+ const linkRootPath = linkRoot ? path.resolve(linkRoot) : roots.linkRoot;
644
+ const ephemeral = !linkRoot && roots.ephemeral;
734
645
  const activeCatalog = catalog ?? await loadSkillCatalog();
735
646
  const catalogByName = new Map(activeCatalog.skills.map(s => [s.name, s]));
736
647
  const uniqueSkills = [...new Set(skillNames ?? [])].sort();
737
648
 
649
+ // A broken link is nexakit's only with proof: the install registry (the exact link), or this project's
650
+ // manifest. An entry written by this version records the exact link too; one from 0.1.0 only has the path,
651
+ // so its link must at least have nexakit's own shape: `<root>/<where this skill lives in nexakit>`.
652
+ const registry = await readRegistry(nexakitDir(home));
653
+ const previousManifest = await readProjectToolkitManifest(root);
654
+ const manifested = new Map((previousManifest?.entries ?? []).filter(entry => entry?.relativePath).map(entry => [entry.relativePath, entry]));
655
+ const ownedBy = suffix => (target, literal) => {
656
+ if (isRegistered(registry, target, literal)) return true;
657
+ const entry = manifested.get(path.relative(root, target).split(path.sep).join('/'));
658
+ if (!entry) return false;
659
+ if (typeof entry.apunta === 'string') return entry.apunta === literal;
660
+ return path.resolve(path.dirname(target), literal).endsWith(path.sep + path.normalize(suffix));
661
+ };
662
+ const activeRoots = [linkRootPath, resolvedToolkit, actualRoot(home)];
663
+
738
664
  const links = [];
739
665
  const verifiedCatalogEntries = new Map();
740
666
 
@@ -759,8 +685,29 @@ export async function planProjectSkillInstall({
759
685
  for (const client of clients) {
760
686
  const target = path.join(root, client === 'codex' ? '.agents' : '.claude', 'skills', skillName);
761
687
  await ensureSafeProjectParents(root, target, false);
688
+ const serves = servesFor(client, agent);
689
+ const suffix = path.relative(resolvedToolkit, source);
690
+ if (mode === 'symlink') {
691
+ const info = await classifyLink({ target, suffix, activeRoots, isOwned: ownedBy(suffix) });
692
+ links.push({
693
+ client, serves, name: skillName, source: path.join(linkRootPath, suffix), target, mode,
694
+ state: COARSE_STATE[info.state], linkState: info.state,
695
+ ...(info.literal && { literal: info.literal }), ...(info.root && { root: info.root })
696
+ });
697
+ continue;
698
+ }
699
+ if ((await lstatOrNull(target))?.isSymbolicLink()) {
700
+ // A link where a copy goes is replaced only when it is provably nexakit's; any other link is the user's.
701
+ const info = await classifyLink({ target, suffix, activeRoots, isOwned: ownedBy(suffix) });
702
+ const ours = ['instalado', 'otra-copia', 'efimero', 'roto-propio'].includes(info.state);
703
+ links.push({
704
+ client, serves, name: skillName, source, target, mode, state: ours ? 'replace' : 'conflict',
705
+ linkState: info.state, ...(info.literal && { literal: info.literal })
706
+ });
707
+ continue;
708
+ }
762
709
  const state = await projectTargetState(source, target, mode, catalogEntry);
763
- links.push({ client, name: skillName, source, target, state, mode });
710
+ links.push({ client, serves, name: skillName, source, target, state, mode });
764
711
  }
765
712
  }
766
713
 
@@ -800,6 +747,10 @@ export async function planProjectSkillInstall({
800
747
  gitIgnore,
801
748
  projectRoot: root,
802
749
  toolkitRoot: resolvedToolkit,
750
+ linkRoot: linkRootPath,
751
+ ephemeral,
752
+ homeDirectory: home,
753
+ agent,
803
754
  links,
804
755
  stateDirPath,
805
756
  manifestPath,
@@ -822,7 +773,8 @@ export async function planProjectSkillInstall({
822
773
  };
823
774
  }
824
775
 
825
- async function copyVerifiedSkillDirectory(sourceDir, targetDir, catalogEntry) {
776
+ async function copyVerifiedSkillDirectory(sourceDir, targetDir, catalogEntry, lock) {
777
+ await lock?.assertHeld();
826
778
  await fs.mkdir(targetDir, { recursive: true, mode: 0o755 });
827
779
  if (catalogEntry) {
828
780
  for (const file of catalogEntry.files) {
@@ -833,15 +785,18 @@ async function copyVerifiedSkillDirectory(sourceDir, targetDir, catalogEntry) {
833
785
  if (actualHash !== file.sha256) {
834
786
  throw new Error(`Integridad SHA-256 rechazada durante la copia de ${catalogEntry.name} (${file.path}).`);
835
787
  }
788
+ await lock?.assertHeld();
836
789
  await fs.mkdir(path.dirname(dstFile), { recursive: true, mode: 0o755 });
837
790
  await fs.writeFile(dstFile, bytes, { mode: 0o644 });
838
791
  }
839
792
  } else {
793
+ await lock?.assertHeld();
840
794
  await fs.cp(sourceDir, targetDir, { recursive: true, dereference: false });
841
795
  }
842
796
  }
843
797
 
844
- async function writeSkillsLockfile(lockfilePath, activeEntriesBySkill) {
798
+ async function writeSkillsLockfile(lockfilePath, activeEntriesBySkill, lock) {
799
+ await lock?.assertHeld();
845
800
  await fs.mkdir(path.dirname(lockfilePath), { recursive: true, mode: 0o755 });
846
801
  const existingInfo = await lstatOrNull(lockfilePath);
847
802
  if (existingInfo && (!existingInfo.isFile() || existingInfo.isSymbolicLink())) {
@@ -882,6 +837,7 @@ async function writeSkillsLockfile(lockfilePath, activeEntriesBySkill) {
882
837
  generatedBy: 'nexakit',
883
838
  skills: sortedSkills
884
839
  };
840
+ await lock?.assertHeld();
885
841
  await fs.writeFile(lockfilePath, JSON.stringify(payload, null, 2) + '\n', { mode: 0o644 });
886
842
  return true;
887
843
  }
@@ -891,13 +847,16 @@ async function writeProjectManifestAndIgnore({
891
847
  mode,
892
848
  gitIgnore,
893
849
  prevManifest,
894
- coupledEntriesMap
850
+ coupledEntriesMap,
851
+ lock,
852
+ answers = prevManifest?.answers
895
853
  }) {
896
854
  const stateDir = path.join(projectRoot, PROJECT_TOOLKIT_DIR);
897
855
  const stateStat = await lstatOrNull(stateDir);
898
856
  if (stateStat && (!stateStat.isDirectory() || stateStat.isSymbolicLink())) {
899
857
  return { manifestWritten: false, gitIgnoreUpdated: false };
900
858
  }
859
+ await lock?.assertHeld();
901
860
  await fs.mkdir(stateDir, { recursive: true, mode: 0o755 });
902
861
 
903
862
  const entries = [...coupledEntriesMap.values()].sort((a, b) =>
@@ -910,17 +869,20 @@ async function writeProjectManifestAndIgnore({
910
869
  if (gitIgnore !== 'none') {
911
870
  const info = await inspectGitIgnoreTarget(projectRoot, gitIgnore);
912
871
  if (!info.conflict && !info.unavailable && info.targetPath) {
872
+ await lock?.assertHeld();
913
873
  await fs.mkdir(path.dirname(info.targetPath), { recursive: true, mode: 0o755 });
914
874
  const currentStat = await lstatOrNull(info.targetPath);
915
875
  if (!currentStat) {
916
876
  if (gitIgnore === 'gitignore') createdIgnoreFile = true;
917
877
  const nextText = upsertManagedIgnoreBlock('', entries.map(e => e.relativePath));
878
+ await lock?.assertHeld();
918
879
  await fs.writeFile(info.targetPath, nextText, { mode: 0o644 });
919
880
  gitIgnoreUpdated = true;
920
881
  } else if (currentStat.isFile() && !currentStat.isSymbolicLink()) {
921
882
  const prevText = await fs.readFile(info.targetPath, 'utf8');
922
883
  const nextText = upsertManagedIgnoreBlock(prevText, entries.map(e => e.relativePath));
923
884
  if (nextText !== prevText) {
885
+ await lock?.assertHeld();
924
886
  await fs.writeFile(info.targetPath, nextText, { mode: 0o644 });
925
887
  }
926
888
  gitIgnoreUpdated = true;
@@ -935,6 +897,7 @@ async function writeProjectManifestAndIgnore({
935
897
  const otherText = await fs.readFile(otherInfo.targetPath, 'utf8');
936
898
  const cleaned = removeManagedIgnoreBlock(otherText);
937
899
  if (cleaned.changed) {
900
+ await lock?.assertHeld();
938
901
  if (!cleaned.content && otherStrategy === 'gitignore' && prevManifest?.createdIgnoreFile) {
939
902
  await fs.unlink(otherInfo.targetPath).catch(() => {});
940
903
  createdIgnoreFile = false;
@@ -953,20 +916,71 @@ async function writeProjectManifestAndIgnore({
953
916
  mode,
954
917
  gitIgnoreStrategy: gitIgnore,
955
918
  createdIgnoreFile,
956
- entries
919
+ entries,
920
+ ...(answers && { answers })
957
921
  };
958
- await fs.writeFile(
959
- path.join(stateDir, PROJECT_MANIFEST_FILE),
960
- JSON.stringify(manifestPayload, null, 2) + '\n',
961
- { mode: 0o644 }
962
- );
922
+ // A manifest that is a link or not a regular file is not nexakit's: it is never written through.
923
+ const manifestPath = path.join(stateDir, PROJECT_MANIFEST_FILE);
924
+ const manifestStat = await lstatOrNull(manifestPath);
925
+ if (manifestStat && (!manifestStat.isFile() || manifestStat.isSymbolicLink())) {
926
+ return { manifestWritten: false, gitIgnoreUpdated };
927
+ }
928
+ const temporary = `${manifestPath}.${process.pid}.tmp`;
929
+ await fs.writeFile(temporary, JSON.stringify(manifestPayload, null, 2) + '\n', { mode: 0o644, flag: 'wx' });
930
+ try {
931
+ await lock?.assertHeld();
932
+ await fs.rename(temporary, manifestPath);
933
+ } finally {
934
+ await fs.rm(temporary, { force: true }); // ours: created exclusively above
935
+ }
963
936
  return { manifestWritten: true, gitIgnoreUpdated };
964
937
  }
965
938
 
966
- export async function applyProjectSkillInstall(plan) {
939
+ // Links are written under the install lock: it prepares the stable copy when running from an npx cache,
940
+ // re-checks the hashes where the links will point, and records every link so a broken one can be proven ours.
941
+ export async function applyProjectSkillInstall(plan, { runNpm, packageSpec, lockOptions, acquire = acquireLock } = {}) {
967
942
  if (!plan || !Array.isArray(plan.links) || !plan.projectRoot) {
968
943
  throw new Error('Se requiere una vista previa válida de instalación de skills.');
969
944
  }
945
+ const mode = plan.mode ?? 'symlink';
946
+ const gitIgnore = plan.gitIgnore ?? 'gitignore';
947
+ // Anything that writes (links, copies, the manifest, ignore rules or the lockfile) runs under the lock.
948
+ // With nothing to write, nothing is written, so a run that changes nothing never creates ~/.nexakit.
949
+ const pending = plan.links.some(l => ['missing', 'replace'].includes(l.state));
950
+ const previous = await readProjectToolkitManifest(plan.projectRoot);
951
+ const settingsChanged = Boolean(previous) && (previous.mode !== mode || previous.gitIgnoreStrategy !== gitIgnore);
952
+ if (!pending && !settingsChanged) return applyProjectSkillInstallLocked(plan, { registryEntries: [], lock: null, writeMetadata: false });
953
+
954
+ const home = plan.homeDirectory ?? os.homedir();
955
+ const lock = await acquire(nexakitDir(home), lockOptions);
956
+ const registryEntries = [];
957
+ try {
958
+ if (mode === 'symlink' && pending) {
959
+ if (plan.ephemeral) {
960
+ const { version } = JSON.parse(await fs.readFile(path.join(plan.toolkitRoot ?? TOOLKIT_ROOT, 'package.json'), 'utf8'));
961
+ await ensureStableInstall({
962
+ homeDirectory: home, version, lock, ...(runNpm && { runNpm }), ...(packageSpec && { packageSpec })
963
+ });
964
+ }
965
+ const linkRoot = plan.linkRoot ?? plan.toolkitRoot ?? TOOLKIT_ROOT;
966
+ if (path.resolve(linkRoot) !== path.resolve(plan.toolkitRoot ?? TOOLKIT_ROOT)) {
967
+ // The files that get linked are the ones in linkRoot, so their hashes must hold there too.
968
+ for (const entry of Object.values(plan.verifiedCatalogEntries ?? {})) {
969
+ const check = await verifyBundledSkillHashes({ skillEntry: entry, toolkitRoot: linkRoot });
970
+ if (!check.ok) throw new Error(check.reason);
971
+ }
972
+ }
973
+ }
974
+ const result = await applyProjectSkillInstallLocked(plan, { registryEntries, lock, writeMetadata: true });
975
+ await recordEntries(nexakitDir(home), registryEntries, lock);
976
+ return result;
977
+ } finally {
978
+ await lock.release();
979
+ }
980
+ }
981
+
982
+ // `lock` is null only when nothing will be written; every write checks it still holds the lock just before.
983
+ async function applyProjectSkillInstallLocked(plan, { registryEntries, lock, writeMetadata }) {
970
984
  const mode = plan.mode ?? 'symlink';
971
985
  const gitIgnore = plan.gitIgnore ?? 'gitignore';
972
986
  const catalogEntries = plan.verifiedCatalogEntries ?? {};
@@ -1008,35 +1022,49 @@ export async function applyProjectSkillInstall(plan) {
1008
1022
  continue;
1009
1023
  }
1010
1024
 
1025
+ await lock?.assertHeld();
1011
1026
  await ensureSafeProjectParents(plan.projectRoot, item.target, true);
1012
- const currentState = await projectTargetState(item.source, item.target, mode, catalogEntry);
1013
- if (currentState === 'conflict') {
1014
- updatedLinks.push({ ...item, state: 'conflict' });
1015
- continue;
1016
- }
1017
- if (currentState === 'installed') {
1018
- if (coupledEntriesMap.has(`${item.client}:${item.name}`)) {
1019
- const list = installedClientsBySkill.get(item.name) ?? [];
1020
- list.push(item.client);
1021
- installedClientsBySkill.set(item.name, list);
1022
- }
1023
- updatedLinks.push({ ...item, state: 'installed' });
1024
- continue;
1025
- }
1026
- if (currentState === 'replace') {
1027
- const currentInfo = await lstatOrNull(item.target);
1028
- if (!currentInfo?.isSymbolicLink()) {
1029
- updatedLinks.push({ ...item, state: 'conflict' });
1027
+ let literal;
1028
+ if (mode === 'symlink') {
1029
+ // Re-reads the link right before writing: if it is no longer what the plan saw, it is left alone.
1030
+ await lock?.assertHeld();
1031
+ const outcome = await relinkIfUnchanged({
1032
+ target: item.target, expectedLiteral: item.linkState === 'falta' ? null : item.literal, source: item.source, relative: true
1033
+ });
1034
+ if (outcome === 'cambio') {
1035
+ updatedLinks.push({ ...item, state: 'conflict', changed: true });
1030
1036
  continue;
1031
1037
  }
1032
- await fs.unlink(item.target);
1033
- }
1034
-
1035
- if (mode === 'symlink') {
1036
- const linkTarget = IS_WINDOWS ? path.resolve(item.source) : path.relative(path.dirname(item.target), item.source);
1037
- await fs.symlink(linkTarget, item.target, IS_WINDOWS ? 'junction' : 'dir');
1038
+ // The registry and the manifest keep what readlink returns, the value that is compared later.
1039
+ literal = await fs.readlink(item.target);
1040
+ registryEntries.push({ destino: item.target, apunta: literal, raiz: plan.linkRoot ?? plan.toolkitRoot });
1038
1041
  } else {
1039
- await copyVerifiedSkillDirectory(item.source, item.target, catalogEntry);
1042
+ const current = await lstatOrNull(item.target);
1043
+ if (current?.isSymbolicLink()) {
1044
+ // Only a link the plan proved to be nexakit's becomes a copy, and only if it did not change since.
1045
+ if (item.state !== 'replace' || !item.literal || await fs.readlink(item.target) !== item.literal) {
1046
+ updatedLinks.push({ ...item, state: 'conflict', changed: true });
1047
+ continue;
1048
+ }
1049
+ await lock?.assertHeld();
1050
+ await fs.unlink(item.target);
1051
+ } else {
1052
+ const currentState = await projectTargetState(item.source, item.target, mode, catalogEntry);
1053
+ if (currentState === 'conflict') {
1054
+ updatedLinks.push({ ...item, state: 'conflict' });
1055
+ continue;
1056
+ }
1057
+ if (currentState === 'installed') {
1058
+ if (coupledEntriesMap.has(`${item.client}:${item.name}`)) {
1059
+ const list = installedClientsBySkill.get(item.name) ?? [];
1060
+ list.push(item.client);
1061
+ installedClientsBySkill.set(item.name, list);
1062
+ }
1063
+ updatedLinks.push({ ...item, state: 'installed' });
1064
+ continue;
1065
+ }
1066
+ }
1067
+ await copyVerifiedSkillDirectory(item.source, item.target, catalogEntry, lock);
1040
1068
  }
1041
1069
 
1042
1070
  created++;
@@ -1045,6 +1073,7 @@ export async function applyProjectSkillInstall(plan) {
1045
1073
  name: item.name,
1046
1074
  relativePath: relPath,
1047
1075
  mode,
1076
+ ...(literal && { apunta: literal }),
1048
1077
  ...(catalogEntry?.revision && { revision: catalogEntry.revision })
1049
1078
  });
1050
1079
  const list = installedClientsBySkill.get(item.name) ?? [];
@@ -1054,7 +1083,7 @@ export async function applyProjectSkillInstall(plan) {
1054
1083
  }
1055
1084
 
1056
1085
  let lockfileWritten = false;
1057
- if (mode === 'copy' && !plan.stateDirConflict && !plan.lockfileConflict && installedClientsBySkill.size > 0) {
1086
+ if (writeMetadata && mode === 'copy' && !plan.stateDirConflict && !plan.lockfileConflict && installedClientsBySkill.size > 0) {
1058
1087
  const activeMap = new Map();
1059
1088
  for (const [skillName, clients] of installedClientsBySkill.entries()) {
1060
1089
  const catalogEntry = catalogEntries[skillName];
@@ -1063,20 +1092,22 @@ export async function applyProjectSkillInstall(plan) {
1063
1092
  if (activeMap.size > 0) {
1064
1093
  lockfileWritten = await writeSkillsLockfile(
1065
1094
  plan.lockfilePath ?? path.join(plan.projectRoot, PROJECT_TOOLKIT_DIR, PROJECT_LOCKFILE_NAME),
1066
- activeMap
1095
+ activeMap,
1096
+ lock
1067
1097
  );
1068
1098
  }
1069
1099
  }
1070
1100
 
1071
1101
  let manifestWritten = false;
1072
1102
  let gitIgnoreUpdated = false;
1073
- if (!plan.stateDirConflict && (coupledEntriesMap.size > 0 || lockfileWritten)) {
1103
+ if (writeMetadata && !plan.stateDirConflict && (coupledEntriesMap.size > 0 || lockfileWritten)) {
1074
1104
  const res = await writeProjectManifestAndIgnore({
1075
1105
  projectRoot: plan.projectRoot,
1076
1106
  mode,
1077
1107
  gitIgnore,
1078
1108
  prevManifest,
1079
- coupledEntriesMap
1109
+ coupledEntriesMap,
1110
+ lock
1080
1111
  });
1081
1112
  manifestWritten = res.manifestWritten;
1082
1113
  gitIgnoreUpdated = res.gitIgnoreUpdated;
@@ -1108,6 +1139,38 @@ export async function applyProjectSkillLinks(plan) {
1108
1139
  return applyProjectSkillInstall({ ...plan, mode: 'symlink' });
1109
1140
  }
1110
1141
 
1142
+ // The answers given to `npx nexakit` (skills chosen and discarded, agents, memory), kept in the project manifest
1143
+ // so a second run knows what was decided and does not ask again.
1144
+ const isNameList = value => Array.isArray(value) && value.every(item => typeof item === 'string' && SKILL_NAME_RE.test(item));
1145
+ function validSetupAnswers(answers) {
1146
+ return Boolean(answers && answers.version === 1 &&
1147
+ isNameList(answers.skills?.chosen) && isNameList(answers.skills?.discarded) &&
1148
+ Array.isArray(answers.agents) && answers.agents.every(agent => ['claude', 'codex', 'antigravity'].includes(agent)) &&
1149
+ (answers.memory?.decision === 'later' ||
1150
+ (answers.memory?.decision === 'path' && typeof answers.memory.path === 'string' && path.isAbsolute(answers.memory.path))));
1151
+ }
1152
+
1153
+ export async function readSetupAnswers(projectRoot) {
1154
+ const manifest = await readProjectToolkitManifest(projectRoot);
1155
+ return validSetupAnswers(manifest?.answers) ? manifest.answers : null;
1156
+ }
1157
+
1158
+ export async function writeSetupAnswers(projectRoot, answers, lock) {
1159
+ if (!validSetupAnswers(answers)) throw new Error('Respuestas de nexakit no válidas.');
1160
+ await lock.assertHeld();
1161
+ const prevManifest = await readProjectToolkitManifest(projectRoot);
1162
+ const result = await writeProjectManifestAndIgnore({
1163
+ projectRoot,
1164
+ mode: prevManifest?.mode ?? 'symlink',
1165
+ gitIgnore: prevManifest?.gitIgnoreStrategy ?? 'gitignore',
1166
+ prevManifest,
1167
+ coupledEntriesMap: new Map((prevManifest?.entries ?? []).map(entry => [entry.relativePath, entry])),
1168
+ lock,
1169
+ answers
1170
+ });
1171
+ if (!result.manifestWritten) throw new Error(`${PROJECT_TOOLKIT_DIR} no es una carpeta normal; no se guardaron las respuestas.`);
1172
+ }
1173
+
1111
1174
  export async function planToolkitDetach({
1112
1175
  projectRoot,
1113
1176
  includeGlobal = false,
@@ -1115,13 +1178,21 @@ export async function planToolkitDetach({
1115
1178
  catalog,
1116
1179
  toolkitRoot = TOOLKIT_ROOT,
1117
1180
  homeDirectory = os.homedir(),
1118
- codexHome
1181
+ codexHome,
1182
+ environment = {},
1183
+ skillNames
1119
1184
  } = {}) {
1120
- if (!['codex', 'claude', 'all'].includes(agent)) {
1121
- throw new Error('El agente debe ser codex, claude o all.');
1185
+ if (!['codex', 'claude', 'antigravity', 'all'].includes(agent)) {
1186
+ throw new Error('El agente debe ser codex, claude, antigravity o all.');
1122
1187
  }
1123
- const clients = agent === 'all' ? ['codex', 'claude'] : [agent];
1188
+ const clients = agent === 'all' ? ['codex', 'claude', 'antigravity'] : [agent];
1189
+ // Antigravity shares Codex's project folder, so removing one's project links removes the other's.
1190
+ const projectClients = [...new Set(clients.map(c => (c === 'antigravity' ? 'codex' : c)))];
1124
1191
  const resolvedToolkit = await fs.realpath(toolkitRoot);
1192
+ const homeReal = await fs.realpath(homeDirectory).catch(() => path.resolve(homeDirectory));
1193
+ // Links made from an npx run point into the stable copy, which is nexakit's too.
1194
+ const stableVersions = path.join(nexakitDir(homeReal), 'versiones');
1195
+ const registry = await readRegistry(nexakitDir(homeReal));
1125
1196
  const activeCatalog = catalog ?? await loadSkillCatalog();
1126
1197
  const catalogByName = new Map(activeCatalog.skills.map(s => [s.name, s]));
1127
1198
 
@@ -1141,17 +1212,19 @@ export async function planToolkitDetach({
1141
1212
 
1142
1213
  let candidateEntries = [];
1143
1214
  if (manifest) {
1144
- candidateEntries = manifest.entries.filter(e => clients.includes(e.client));
1145
- remainingManifestEntries = manifest.entries.filter(e => !clients.includes(e.client));
1215
+ // With skillNames only those skills go; the rest of the entries stay in the manifest.
1216
+ const removing = e => projectClients.includes(e.client) && (!skillNames || skillNames.includes(e.name));
1217
+ candidateEntries = manifest.entries.filter(removing);
1218
+ remainingManifestEntries = manifest.entries.filter(e => !removing(e));
1146
1219
  } else {
1147
- const extLinks = await findFallbackExternalToolkitLinks(root, resolvedToolkit, clients, homeDirectory);
1220
+ const extLinks = await findFallbackExternalToolkitLinks(root, resolvedToolkit, projectClients, homeDirectory);
1148
1221
  const seenKeys = new Set(extLinks.map(e => `${e.client}:${e.name}`));
1149
1222
  candidateEntries = [...extLinks];
1150
1223
  if (legacyLock) {
1151
1224
  for (const [skillName, meta] of Object.entries(legacyLock.skills ?? {})) {
1152
1225
  const lockClients = Array.isArray(meta?.clients) ? meta.clients : ['codex', 'claude'];
1153
1226
  for (const client of lockClients) {
1154
- if (!clients.includes(client) || seenKeys.has(`${client}:${skillName}`)) continue;
1227
+ if (!projectClients.includes(client) || seenKeys.has(`${client}:${skillName}`)) continue;
1155
1228
  candidateEntries.push({
1156
1229
  client,
1157
1230
  name: skillName,
@@ -1163,6 +1236,7 @@ export async function planToolkitDetach({
1163
1236
  }
1164
1237
  }
1165
1238
 
1239
+ if (skillNames) candidateEntries = candidateEntries.filter(entry => skillNames.includes(entry.name));
1166
1240
  for (const entry of candidateEntries) {
1167
1241
  if (!SKILL_NAME_RE.test(entry.name)) continue;
1168
1242
  const target = path.join(root, entry.client === 'codex' ? '.agents' : '.claude', 'skills', entry.name);
@@ -1174,7 +1248,7 @@ export async function planToolkitDetach({
1174
1248
  let safeToUnlink = Boolean(manifest);
1175
1249
  try {
1176
1250
  const real = await fs.realpath(target);
1177
- safeToUnlink = isWithinRoot(real, resolvedToolkit) && !isWithinRoot(real, root);
1251
+ safeToUnlink = (isWithinRoot(real, resolvedToolkit) || isWithinRoot(real, stableVersions)) && !isWithinRoot(real, root);
1178
1252
  } catch (err) {
1179
1253
  if (err.code !== 'ENOENT') throw err;
1180
1254
  }
@@ -1249,7 +1323,7 @@ export async function planToolkitDetach({
1249
1323
  const manualCommands = [];
1250
1324
  if (includeGlobal) {
1251
1325
  const home = await fs.realpath(homeDirectory);
1252
- const resolvedCodexHome = codexHome ?? path.join(home, '.codex');
1326
+ const resolvedCodexHome = codexHome ?? environment.CODEX_HOME ?? path.join(home, '.codex');
1253
1327
  const bundledSkillsDir = path.join(resolvedToolkit, 'skills');
1254
1328
  const bundledNames = (await fs.readdir(bundledSkillsDir, { withFileTypes: true }).catch(() => []))
1255
1329
  .filter(e => e.isDirectory() && SKILL_NAME_RE.test(e.name))
@@ -1258,24 +1332,25 @@ export async function planToolkitDetach({
1258
1332
 
1259
1333
  for (const client of clients) {
1260
1334
  for (const name of bundledNames) {
1261
- const expectedSource = path.join(resolvedToolkit, 'skills', name);
1262
- const target = path.join(home, client === 'codex' ? '.agents' : '.claude', 'skills', name);
1335
+ const target = globalSkillTarget(home, client, name);
1263
1336
  const info = await lstatOrNull(target);
1264
1337
  if (!info?.isSymbolicLink()) continue;
1265
- try {
1266
- const [actual, expected] = await Promise.all([fs.realpath(target), fs.realpath(expectedSource)]);
1267
- if (normalizedPath(actual) === normalizedPath(expected)) {
1268
- items.push({
1269
- scope: 'global',
1270
- client,
1271
- name,
1272
- target,
1273
- relativePath: target,
1274
- action: 'remove-global-link',
1275
- reason: 'enlace global de Agent Toolkit'
1276
- });
1277
- }
1278
- } catch {}
1338
+ // Only links that are provably nexakit's: into this copy, the stable copy, an npx cache, or recorded broken.
1339
+ const link = await classifyLink({
1340
+ target, suffix: `skills/${name}`, activeRoots: [resolvedToolkit, actualRoot(home)],
1341
+ isOwned: (at, literal) => isRegistered(registry, at, literal)
1342
+ });
1343
+ if (['instalado', 'efimero', 'roto-propio'].includes(link.state)) {
1344
+ items.push({
1345
+ scope: 'global',
1346
+ client,
1347
+ name,
1348
+ target,
1349
+ relativePath: target,
1350
+ action: 'remove-global-link',
1351
+ reason: 'enlace global de Agent Toolkit'
1352
+ });
1353
+ }
1279
1354
  }
1280
1355
  }
1281
1356
 
@@ -1305,6 +1380,16 @@ export async function planToolkitDetach({
1305
1380
  }
1306
1381
 
1307
1382
  return {
1383
+ reviewInstructions: includeGlobal && !skillNames
1384
+ ? await ownedReviewInstructions({
1385
+ homeDirectory: homeReal,
1386
+ codexHome: codexHome ?? environment.CODEX_HOME ?? path.join(homeReal, '.codex'),
1387
+ agents: clients
1388
+ })
1389
+ : [],
1390
+ homeDirectory: homeReal,
1391
+ reviewCodexHome: codexHome ?? environment.CODEX_HOME ?? path.join(homeReal, ".codex"),
1392
+ reviewAgents: clients,
1308
1393
  applied: false,
1309
1394
  projectRoot: root,
1310
1395
  toolkitRoot: resolvedToolkit,
@@ -1363,6 +1448,18 @@ export async function applyToolkitDetach(plan) {
1363
1448
  throw new Error('Se requiere una vista previa válida de desacoplado.');
1364
1449
  }
1365
1450
 
1451
+ if (plan.reviewInstructions?.length) {
1452
+ const lock = await acquireLock(nexakitDir(plan.homeDirectory));
1453
+ try {
1454
+ await removeReviewInstructions({
1455
+ homeDirectory: plan.homeDirectory,
1456
+ codexHome: plan.reviewCodexHome,
1457
+ agents: plan.reviewAgents
1458
+ }, lock);
1459
+ } finally {
1460
+ await lock.release();
1461
+ }
1462
+ }
1366
1463
  let removedCount = 0;
1367
1464
  let conflicts = 0;
1368
1465
  const updatedItems = [];
@@ -1556,5 +1653,21 @@ export function formatDetachReport(report) {
1556
1653
  for (const cmd of report.manualCommands) lines.push(` $ ${cmd}`);
1557
1654
  }
1558
1655
 
1656
+ if (report.reviewInstructions?.length) lines.push(` - Quitar solo el bloque de revisión de ${report.reviewInstructions.join(', ')}`);
1559
1657
  return lines.join('\n') + '\n';
1560
1658
  }
1659
+
1660
+ // Generic signals: almost every project has them, and the useful skill is the framework's.
1661
+ const GENERIC_SIGNALS = new Set(['nodejs', 'typescript']);
1662
+
1663
+ // The technologies found in the project that no catalog skill (nor external reference) covers. Only reported:
1664
+ // finding a skill for one is a request the person makes to revisar-skill, never a search nexakit starts alone.
1665
+ export function uncoveredSignals({ signals = [], catalog }) {
1666
+ const covered = new Set([
1667
+ ...(catalog?.skills ?? []).flatMap((skill) => skill.matching?.anySignals ?? []),
1668
+ ...(catalog?.externalReferences ?? []).flatMap((ref) => ref.anySignals ?? []),
1669
+ ]);
1670
+ return signals
1671
+ .filter((signal) => !GENERIC_SIGNALS.has(signal.id) && !covered.has(signal.id))
1672
+ .map((signal) => ({ id: signal.id, label: signal.label }));
1673
+ }