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
@@ -0,0 +1,339 @@
1
+ // Detects the technologies of a project from its root and its first-level subfolders: from the manifests, or from
2
+ // the README keywords in a folder without manifests. Each signal keeps where it came from, so a suggestion can
3
+ // say why. Read-only and bounded.
4
+ import { spawnSync } from 'node:child_process';
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+
8
+ const MAX_MANIFEST_BYTES = 512 * 1024;
9
+ const MAX_README_BYTES = 64 * 1024;
10
+ const MAX_SUBFOLDERS = 20;
11
+
12
+ export const SIGNAL_LABELS = Object.freeze({
13
+ nodejs: 'Node.js',
14
+ typescript: 'TypeScript',
15
+ 'mcp-sdk': 'MCP SDK',
16
+ react: 'React',
17
+ nextjs: 'Next.js',
18
+ tailwindcss: 'Tailwind CSS',
19
+ astro: 'Astro',
20
+ vue: 'Vue',
21
+ svelte: 'Svelte',
22
+ supabase: 'Supabase',
23
+ postgres: 'PostgreSQL',
24
+ express: 'Express',
25
+ fastify: 'Fastify',
26
+ hono: 'Hono',
27
+ playwright: 'Playwright',
28
+ vitest: 'Vitest',
29
+ django: 'Django',
30
+ fastapi: 'FastAPI',
31
+ flask: 'Flask',
32
+ tauri: 'Tauri',
33
+ python: 'Python',
34
+ rust: 'Rust',
35
+ 'rest-api': 'API REST'
36
+ });
37
+
38
+ export const PACKAGE_TO_SIGNAL = Object.freeze({
39
+ '@modelcontextprotocol/sdk': 'mcp-sdk',
40
+ '@modelcontextprotocol/server': 'mcp-sdk',
41
+ '@modelcontextprotocol/client': 'mcp-sdk',
42
+ fastmcp: 'mcp-sdk',
43
+ next: 'nextjs',
44
+ react: 'react',
45
+ 'react-dom': 'react',
46
+ tailwindcss: 'tailwindcss',
47
+ '@tailwindcss/postcss': 'tailwindcss',
48
+ '@tailwindcss/vite': 'tailwindcss',
49
+ typescript: 'typescript',
50
+ astro: 'astro',
51
+ vue: 'vue',
52
+ nuxt: 'vue',
53
+ svelte: 'svelte',
54
+ '@sveltejs/kit': 'svelte',
55
+ '@supabase/supabase-js': 'supabase',
56
+ '@supabase/ssr': 'supabase',
57
+ pg: 'postgres',
58
+ postgres: 'postgres',
59
+ '@neondatabase/serverless': 'postgres',
60
+ 'drizzle-orm': 'postgres',
61
+ '@prisma/client': 'postgres',
62
+ express: 'express',
63
+ fastify: 'fastify',
64
+ hono: 'hono',
65
+ '@hono/node-server': 'hono',
66
+ playwright: 'playwright',
67
+ '@playwright/test': 'playwright',
68
+ vitest: 'vitest',
69
+ '@tauri-apps/api': 'tauri',
70
+ '@tauri-apps/cli': 'tauri'
71
+ });
72
+
73
+ const PYTHON_PACKAGE_TO_SIGNALS = Object.freeze({
74
+ django: ['django'],
75
+ djangorestframework: ['django', 'rest-api'],
76
+ fastapi: ['fastapi'],
77
+ flask: ['flask']
78
+ });
79
+
80
+ const FRAMEWORK_CONFIG_HINTS = Object.freeze([
81
+ ['next.config.js', 'nextjs'],
82
+ ['next.config.mjs', 'nextjs'],
83
+ ['next.config.ts', 'nextjs'],
84
+ ['tailwind.config.js', 'tailwindcss'],
85
+ ['tailwind.config.ts', 'tailwindcss'],
86
+ ['astro.config.mjs', 'astro'],
87
+ ['svelte.config.js', 'svelte']
88
+ ]);
89
+
90
+ const OUT_OF_COVERAGE_MANIFESTS = Object.freeze(['go.mod', 'composer.json', 'Gemfile']);
91
+ // A folder with any of these is described by them; its README is read only when it has none. A README often lists
92
+ // technologies it documents or talks about (nexakit's own README names every catalog technology) without using them.
93
+ const PROJECT_MANIFESTS = Object.freeze(['package.json', 'tsconfig.json', 'requirements.txt', 'pyproject.toml', 'manage.py',
94
+ 'Cargo.toml', 'tauri.conf.json', 'src-tauri/Cargo.toml', 'src-tauri/tauri.conf.json', ...OUT_OF_COVERAGE_MANIFESTS]);
95
+
96
+ // README dictionary. Case-insensitive, whole words only: "Reactor", "vue-router" or "flask_utils" do not count,
97
+ // "Express.js" does.
98
+ const README_TERMS = Object.freeze([
99
+ [['React'], 'react'], [['Next.js'], 'nextjs'], [['Vue'], 'vue'], [['Svelte'], 'svelte'], [['Astro'], 'astro'],
100
+ [['TypeScript'], 'typescript'], [['Tailwind'], 'tailwindcss'], [['Django'], 'django'], [['FastAPI'], 'fastapi'],
101
+ [['Flask'], 'flask'], [['Express'], 'express'], [['Fastify'], 'fastify'], [['Hono'], 'hono'], [['Tauri'], 'tauri'],
102
+ [['Supabase'], 'supabase'], [['PostgreSQL'], 'postgres'], [['Playwright'], 'playwright'], [['MCP'], 'mcp-sdk'],
103
+ [['API REST', 'REST API', 'OpenAPI'], 'rest-api']
104
+ ].map(([terms, signal]) => {
105
+ const escaped = terms.map(term => term.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&').replace(/ /gu, '\\s+'));
106
+ return [new RegExp(`(?<![\\p{L}\\p{N}_.-])(?:${escaped.join('|')})(?![\\p{L}\\p{N}_-])`, 'iu'), signal];
107
+ }));
108
+
109
+ const lstatOrNull = target => fs.lstat(target).catch(err => {
110
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null;
111
+ throw err;
112
+ });
113
+ const isPlainFile = info => Boolean(info?.isFile() && !info.isSymbolicLink());
114
+
115
+ async function readHead(file, maxBytes) {
116
+ const handle = await fs.open(file, 'r');
117
+ try {
118
+ const buffer = Buffer.alloc(maxBytes);
119
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
120
+ return buffer.subarray(0, bytesRead).toString('utf8');
121
+ } finally {
122
+ await handle.close();
123
+ }
124
+ }
125
+
126
+ // Folders Git ignores, asked to Git itself so every .gitignore rule (nested, negated, global) is respected.
127
+ function gitIgnored(root, names) {
128
+ if (names.length === 0) return new Set();
129
+ const result = spawnSync('git', ['check-ignore', '-z', '--stdin'], {
130
+ cwd: root, input: names.map(name => `${name}/\0`).join(''), encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore']
131
+ });
132
+ if (result.error || (result.status !== 0 && result.status !== 1)) return new Set();
133
+ return new Set(result.stdout.split('\0').filter(Boolean).map(entry => entry.replace(/\/$/u, '')));
134
+ }
135
+
136
+ async function foldersToScan(root, git, limits) {
137
+ const entries = await fs.readdir(root, { withFileTypes: true });
138
+ let names = entries
139
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' && !/[\n\0]/u.test(entry.name))
140
+ .map(entry => entry.name)
141
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
142
+ if (git) {
143
+ const ignored = gitIgnored(root, names);
144
+ names = names.filter(name => !ignored.has(name));
145
+ }
146
+ if (names.length > MAX_SUBFOLDERS) limits.push(`Se revisaron ${MAX_SUBFOLDERS} de ${names.length} subcarpetas.`);
147
+ return ['.', ...names.slice(0, MAX_SUBFOLDERS)];
148
+ }
149
+
150
+ // Reads the TOML array that starts at `text[0]` ('['). Returns where it ends and the strings it holds directly or in
151
+ // nested arrays, skipping comments and inline tables (`{ include-group = "dev" }` names a group, not a package).
152
+ // Double-quoted strings honour backslash escapes; single-quoted ones are literal.
153
+ function tomlArrayStrings(text) {
154
+ const strings = [];
155
+ let depth = 0, braces = 0;
156
+ for (let i = 0; i < text.length; i++) {
157
+ const ch = text[i];
158
+ if (ch === '"' || ch === "'") {
159
+ let value = '', j = i + 1;
160
+ for (; j < text.length && text[j] !== ch && text[j] !== '\n'; j++) {
161
+ if (ch === '"' && text[j] === '\\' && j + 1 < text.length) j++;
162
+ value += text[j];
163
+ }
164
+ if (braces === 0) strings.push(value);
165
+ i = j;
166
+ } else if (ch === '#') {
167
+ const newline = text.indexOf('\n', i);
168
+ if (newline < 0) break;
169
+ i = newline;
170
+ } else if (ch === '{') braces++;
171
+ else if (ch === '}') braces--;
172
+ else if (ch === '[') depth++;
173
+ else if (ch === ']' && --depth === 0) return { end: i, strings };
174
+ }
175
+ return { end: -1, strings: [] };
176
+ }
177
+
178
+ // Dependency names declared in a pyproject.toml, without a TOML parser: PEP 621 `[project] dependencies`,
179
+ // `[project.optional-dependencies]` and `[dependency-groups]` arrays, and Poetry `[tool.poetry.*dependencies]` keys.
180
+ // Metadata such as `keywords` or `description` is never read.
181
+ function pyprojectDependencies(text) {
182
+ const names = new Set();
183
+ const nameOf = spec => spec.match(/^\s*([A-Za-z0-9._-]+)/u)?.[1].toLowerCase().replace(/_/gu, '-');
184
+ const lines = text.split(/\r?\n/u);
185
+ let table = '';
186
+ for (let index = 0; index < lines.length; index++) {
187
+ const line = lines[index];
188
+ const header = line.match(/^\s*(\[\[?)\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/u);
189
+ if (header) {
190
+ // Arrays of tables ([[…]]) never hold dependencies here; quoted parts stay quoted so they never match.
191
+ table = header[1] === '[[' ? null : header[2].replace(/\s+/gu, '');
192
+ continue;
193
+ }
194
+ const key = line.match(/^\s*([A-Za-z0-9._-]+|"[^"]*"|'[^']*')\s*=\s*(.*)$/u);
195
+ if (!key || table === null) continue;
196
+ const keyName = key[1].replace(/^["']|["']$/gu, '');
197
+ if (/^tool\.poetry(?:\.group\.[^.]+)?\.(?:dev-)?dependencies$/u.test(table)) {
198
+ const name = nameOf(keyName);
199
+ if (name) names.add(name);
200
+ continue;
201
+ }
202
+ const holdsArray = (table === 'project' && keyName === 'dependencies')
203
+ || table === 'project.optional-dependencies' || table === 'dependency-groups';
204
+ if (!holdsArray || !key[2].trimStart().startsWith('[')) continue;
205
+ const rest = [key[2].trimStart(), ...lines.slice(index + 1)].join('\n');
206
+ const { end, strings } = tomlArrayStrings(rest);
207
+ if (end < 0) continue;
208
+ for (const spec of strings) {
209
+ const name = nameOf(spec);
210
+ if (name) names.add(name);
211
+ }
212
+ index += rest.slice(0, end).split('\n').length - 1;
213
+ }
214
+ return names;
215
+ }
216
+
217
+ function pythonPackages(text, kind) {
218
+ const found = new Set();
219
+ if (kind === 'requirements') {
220
+ for (const raw of text.split(/\r?\n/u)) {
221
+ const line = raw.trim();
222
+ if (!line || line.startsWith('#') || line.startsWith('-')) continue;
223
+ const name = line.match(/^[A-Za-z0-9._-]+/u)?.[0].toLowerCase().replace(/_/gu, '-');
224
+ if (name && PYTHON_PACKAGE_TO_SIGNALS[name]) found.add(name);
225
+ }
226
+ } else {
227
+ for (const name of pyprojectDependencies(text)) if (PYTHON_PACKAGE_TO_SIGNALS[name]) found.add(name);
228
+ }
229
+ return [...found].sort();
230
+ }
231
+
232
+ async function scanFolder(root, folder, add, limits, { nestedAllowed }) {
233
+ const dir = path.join(root, folder);
234
+ const shown = name => (folder === '.' ? name : `${folder}/${name}`);
235
+ // Nested manifests (src-tauri/…) are read only through a real folder that Git does not ignore: a link could
236
+ // lead out of the project.
237
+ const nestedDir = nestedAllowed ? lstatOrNull(path.join(dir, 'src-tauri')) : Promise.resolve(null);
238
+ const fileInfo = async name => {
239
+ if (name.includes('/')) {
240
+ const info = await nestedDir;
241
+ if (!info?.isDirectory() || info.isSymbolicLink()) return null;
242
+ }
243
+ return lstatOrNull(path.join(dir, name));
244
+ };
245
+ const readSmall = async name => {
246
+ const info = await fileInfo(name);
247
+ return isPlainFile(info) && info.size <= MAX_MANIFEST_BYTES ? fs.readFile(path.join(dir, name), 'utf8') : null;
248
+ };
249
+ const fromPackageJson = new Set();
250
+
251
+ const pkgInfo = await fileInfo('package.json');
252
+ if (pkgInfo) {
253
+ if (!isPlainFile(pkgInfo) || pkgInfo.size > MAX_MANIFEST_BYTES) {
254
+ limits.push(`${shown('package.json')} excede el límite o no es un archivo regular; no se inspeccionó.`);
255
+ } else {
256
+ let pkg = null;
257
+ try { pkg = JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8')); } catch {
258
+ limits.push(`${shown('package.json')} no contiene JSON válido; no se dedujeron dependencias Node.`);
259
+ }
260
+ if (pkg && typeof pkg === 'object' && !Array.isArray(pkg)) {
261
+ add('nodejs', shown('package.json'), 'manifest');
262
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
263
+ const deps = pkg[field];
264
+ if (!deps || typeof deps !== 'object' || Array.isArray(deps)) continue;
265
+ for (const dep of Object.keys(deps)) {
266
+ const signal = PACKAGE_TO_SIGNAL[dep];
267
+ if (signal) { add(signal, `${shown('package.json')} (${dep})`, 'manifest'); fromPackageJson.add(signal); }
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
273
+ if (isPlainFile(await fileInfo('tsconfig.json'))) add('typescript', shown('tsconfig.json'), 'manifest');
274
+ for (const [config, signal] of FRAMEWORK_CONFIG_HINTS) {
275
+ if (isPlainFile(await fileInfo(config)) && !fromPackageJson.has(signal)) {
276
+ limits.push(`Configuración observada sin dependencia confirmada en package.json: ${shown(config)}.`);
277
+ }
278
+ }
279
+
280
+ for (const [name, kind] of [['requirements.txt', 'requirements'], ['pyproject.toml', 'pyproject']]) {
281
+ const text = await readSmall(name);
282
+ if (text === null) continue;
283
+ add('python', shown(name), 'manifest');
284
+ for (const pkgName of pythonPackages(text, kind)) {
285
+ for (const signal of PYTHON_PACKAGE_TO_SIGNALS[pkgName]) add(signal, `${shown(name)} (${pkgName})`, 'manifest');
286
+ }
287
+ }
288
+ if (isPlainFile(await fileInfo('manage.py'))) {
289
+ add('python', shown('manage.py'), 'manifest');
290
+ add('django', shown('manage.py'), 'manifest');
291
+ }
292
+ for (const cargo of ['Cargo.toml', 'src-tauri/Cargo.toml']) {
293
+ const text = await readSmall(cargo);
294
+ if (text === null) continue;
295
+ add('rust', shown(cargo), 'manifest');
296
+ if (/^\s*tauri\s*=/mu.test(text)) add('tauri', `${shown(cargo)} (tauri)`, 'manifest');
297
+ }
298
+ for (const conf of ['tauri.conf.json', 'src-tauri/tauri.conf.json']) {
299
+ if (isPlainFile(await fileInfo(conf))) add('tauri', shown(conf), 'manifest');
300
+ }
301
+ for (const foreign of OUT_OF_COVERAGE_MANIFESTS) {
302
+ const info = await fileInfo(foreign);
303
+ if (info && !info.isSymbolicLink()) limits.push(`Stack fuera de cobertura automática observada: ${shown(foreign)}.`);
304
+ }
305
+
306
+ for (const manifest of PROJECT_MANIFESTS) if (await fileInfo(manifest)) return;
307
+ const entries = await fs.readdir(dir, { withFileTypes: true });
308
+ const readme = entries.filter(entry => entry.name.toLowerCase() === 'readme.md').map(entry => entry.name).sort()[0];
309
+ if (readme && isPlainFile(await fileInfo(readme))) {
310
+ const text = await readHead(path.join(dir, readme), MAX_README_BYTES);
311
+ for (const [pattern, signal] of README_TERMS) if (pattern.test(text)) add(signal, shown(readme), 'readme');
312
+ }
313
+ }
314
+
315
+ export async function detectSignals(root, { git = false } = {}) {
316
+ const limits = [];
317
+ const found = new Map();
318
+ const add = (id, source, kind) => {
319
+ const sources = found.get(id) ?? new Map();
320
+ if (!sources.has(source)) sources.set(source, kind);
321
+ found.set(id, sources);
322
+ };
323
+ const scanned = await foldersToScan(root, git, limits);
324
+ const nestedIgnored = git ? gitIgnored(root, scanned.map(folder => (folder === '.' ? 'src-tauri' : `${folder}/src-tauri`))) : new Set();
325
+ for (const folder of scanned) {
326
+ const nested = folder === '.' ? 'src-tauri' : `${folder}/src-tauri`;
327
+ await scanFolder(root, folder, add, limits, { nestedAllowed: !nestedIgnored.has(nested) });
328
+ }
329
+
330
+ const byPath = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
331
+ const signals = [...found.entries()]
332
+ .map(([id, sources]) => {
333
+ const of = kind => [...sources].filter(([, k]) => k === kind).map(([source]) => source).sort(byPath);
334
+ return { id, label: SIGNAL_LABELS[id] ?? id, sources: [...of('manifest'), ...of('readme')] };
335
+ })
336
+ .sort((a, b) => byPath(a.id, b.id));
337
+ if (signals.length === 0) limits.push('No se detectaron tecnologías en la raíz ni en sus subcarpetas.');
338
+ return { signals, limits, scanned };
339
+ }
@@ -0,0 +1,77 @@
1
+ // Plain-text report of `npx nexakit recommend`: what was detected and which skills fit, read-only.
2
+ export function formatRecommendationReport(report) {
3
+ const lines = [
4
+ `Agent Toolkit · Recomendaciones para ${report.projectName}`,
5
+ `Raíz: ${report.projectRoot}${report.profile ? ` · Perfil: ${report.profile}` : ''}${report.client ? ` · Cliente: ${report.client}` : ''}`,
6
+ '',
7
+ 'Tecnologías detectadas:'
8
+ ];
9
+
10
+ if (report.signals.length === 0) {
11
+ lines.push(' - Ninguna tecnología cubierta detectada automáticamente.');
12
+ } else {
13
+ for (const sig of report.signals) {
14
+ lines.push(` ✓ ${sig.label} ← ${sig.sources.join(', ')}`);
15
+ }
16
+ }
17
+ for (const unm of report.unmatchedSignals) {
18
+ lines.push(` • ${unm.label} (sin skill empaquetada aún)`);
19
+ }
20
+
21
+ lines.push('', `Recomendaciones con escenario probado (${report.recommended.length}):`);
22
+ if (report.recommended.length === 0) {
23
+ lines.push(' - Ninguna cumple todavía los 4 criterios de escenario probado para este filtro.');
24
+ } else {
25
+ for (const rec of report.recommended) {
26
+ lines.push(` - ${rec.name} (${rec.license}) ← ${rec.reasons.join(', ')} [tarea: ${rec.taskClass}]`);
27
+ }
28
+ }
29
+
30
+ lines.push('', `Skills compatibles, no probadas (${report.candidates.length}):`);
31
+ if (report.candidates.length === 0) {
32
+ lines.push(' - Ninguna skill específica adicional coincide con las señales actuales.');
33
+ } else {
34
+ for (const cand of report.candidates) {
35
+ const state = cand.installed ? ` · en proyecto: ${cand.installed.integrity}` : '';
36
+ lines.push(` - ${cand.name} (${cand.license}, SHA-256 ✓) ← ${cand.reasons.join(', ')}${state}`);
37
+ lines.push(` Utilidad prevista: ${cand.summary}`);
38
+ lines.push(' No probada: nadie ha comprobado aún que mejore el trabajo con este agente.');
39
+ if (cand.reviewStatus === 'reviewed-with-observations') lines.push(` Observaciones: ${cand.reviewNotes}`);
40
+ for (const reason of cand.pendingReasons) lines.push(` Pendiente: ${reason}`);
41
+ if (cand.overlapsWith.length) lines.push(` Comparte ámbito con: ${cand.overlapsWith.join(', ')}`);
42
+ }
43
+ }
44
+
45
+ if (report.optional && report.optional.length > 0) {
46
+ lines.push('', `Opcionales disponibles en el catálogo (${report.optional.length}):`);
47
+ for (const opt of report.optional) {
48
+ lines.push(` - ${opt.name} (${opt.license}) ← perfil ${opt.profiles.join('/')} · no probada`);
49
+ }
50
+ }
51
+
52
+ if (report.externalCandidates?.length > 0) {
53
+ lines.push('', `Disponibles fuera del catálogo, se instalan aparte (${report.externalCandidates.length}):`);
54
+ for (const ref of report.externalCandidates) {
55
+ lines.push(` - ${ref.name} ← ${ref.reasons.join(', ')} · no probada`);
56
+ lines.push(` Utilidad prevista: ${ref.summary}`);
57
+ lines.push(` Dónde: ${ref.sourceUrl}`);
58
+ lines.push(` Nota: ${ref.statusNote}`);
59
+ }
60
+ }
61
+
62
+ if (report.addons.length > 0) {
63
+ lines.push('', 'Añadidos:');
64
+ for (const addon of report.addons) {
65
+ const kind = addon.optional ? 'opcional' : 'obligatorio';
66
+ lines.push(` - ${addon.name} (${addon.versionReviewed}, ${addon.license}, ${kind}): ${addon.summary}`);
67
+ }
68
+ }
69
+
70
+ if (report.limits.length > 0) {
71
+ lines.push('', 'Límites de la inspección:');
72
+ for (const lim of report.limits) lines.push(` ! ${lim}`);
73
+ }
74
+ lines.push('', 'SHA-256 comprueba integridad de archivos; no acredita utilidad ni calidad.');
75
+
76
+ return lines.join('\n') + '\n';
77
+ }
@@ -0,0 +1,211 @@
1
+ import { RECOMMENDED_REVIEWERS } from './review-policy.js';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { randomUUID, createHash } from 'node:crypto';
5
+ const START = '<!-- nexakit:review:start -->',
6
+ END = '<!-- nexakit:review:end -->';
7
+ const BODY = `${START}
8
+ # Método de trabajo de nexakit
9
+
10
+ Para programar sigue el proceso de Superpowers (obligatorio): contexto y
11
+ memoria, diseño y plan proporcionales, rama, pruebas, implementación,
12
+ verificación y cierre con memoria. No hace falta un comando para empezar.
13
+ Sobre sus skills mandan: modo tutor (la persona escribe el código, un paso
14
+ verificable cada vez); no borrar código existente sin acuerdo aunque el TDD lo
15
+ pida; commit, push, PR y memoria solo con autorización. Reutiliza un diseño o
16
+ plan ya aprobado. Antes de un PR o de la revisión cruzada, quality-gate.
17
+
18
+ Al terminar una tarea con cambios de código, pide automáticamente UNA revisión
19
+ a otra IA, corrige los hallazgos verificables y resume el resultado. Máximo dos
20
+ rondas por tarea; si queda algo grave, avisa. Solo documentos no dispara revisión.
21
+ Si el usuario dice «sin revisión», omítela. /revisar pide una revisión adicional.
22
+ No uses hooks ni subagentes para automatizarlo.
23
+
24
+ Lee la skill revision-cruzada para el encargo y la ejecución de solo lectura.
25
+ Usa elegir-modelo para nombres reales y esfuerzos. Nunca cambies solo el modelo.
26
+ Usa la orden estable ~/.nexakit/nexakit (Windows: nexakit.cmd en esa carpeta).
27
+ Un fallo del comando o informe vacío no significa «sin hallazgos».
28
+
29
+ La IA revisora se elige en la terminal durante la sesión, nunca en el arranque
30
+ ni en answers. Excluye siempre a la IA que programa. Antes de cada revisión ejecuta
31
+ \`nexakit revision --disponibles --principal <IA que programa>\` (claude,
32
+ codex o antigravity), sin llamadas a modelos. Ofrece las otras IA instaladas
33
+ con certificación «sí» o «pendiente» (avisa si la primera vez se certifican);
34
+ nunca las «no». Recomendadas: Claude→${RECOMMENDED_REVIEWERS.claude},
35
+ Codex→${RECOMMENDED_REVIEWERS.codex} y Antigravity→${RECOMMENDED_REVIEWERS.antigravity}.
36
+ Primera revisión con varias opciones: «¿A dónde mando la revisión?» marcando
37
+ la recomendada (Claude: selector; Codex/Antigravity: pregunta normal). Una
38
+ opción: úsala sin preguntar; ninguna: avisa que no hay revisión; si falta
39
+ certificar un perfil, infórmalo. Después conserva la elección en la sesión y
40
+ anuncia «Revisando con <IA>…». «que lo revise <IA>» cambia la elección. Si
41
+ desaparece, usa la única alternativa o pregunta entre las restantes. No persistas
42
+ la elección.
43
+
44
+ Si una tarea larga queda a medias —pausa, cambio de IA, bloqueo o cierre— escribe o actualiza la nota de seguimiento con project-handoff; recomienda sesión nueva en los casos de project-handoff, y cierra el seguimiento al integrar con cerrar-memoria.
45
+ ${END}`;
46
+
47
+ const hash = (text) => createHash('sha256').update(text).digest('hex');
48
+ const proofPath = (home) => path.join(home, '.nexakit', 'review-instructions.json');
49
+ const NAMES = { claude: 'CLAUDE.md', codex: 'AGENTS.md', antigravity: 'GEMINI.md' };
50
+ function targets({ homeDirectory, environment = {}, codexHome, agents = Object.keys(NAMES) }) {
51
+ if (!homeDirectory || !path.isAbsolute(homeDirectory))
52
+ throw new Error('Se requiere homeDirectory absoluto.');
53
+ const folders = {
54
+ claude: path.join(homeDirectory, '.claude'),
55
+ codex: codexHome ?? environment.CODEX_HOME ?? path.join(homeDirectory, '.codex'),
56
+ antigravity: path.join(homeDirectory, '.gemini'),
57
+ };
58
+ return agents
59
+ .filter((client) => NAMES[client])
60
+ .map((client) => ({ client, name: NAMES[client], target: path.join(folders[client], NAMES[client]) }));
61
+ }
62
+ async function readText(file) {
63
+ try {
64
+ const stat = await fs.lstat(file);
65
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
66
+ throw new Error('Archivo de instrucciones o registro en conflicto.');
67
+ return await fs.readFile(file, 'utf8');
68
+ } catch (error) {
69
+ if (error.code === 'ENOENT') return null;
70
+ throw error;
71
+ }
72
+ }
73
+ async function checkParent(directory, create = false) {
74
+ const info = await fs.lstat(directory).catch((error) => {
75
+ if (error.code === 'ENOENT') return null;
76
+ throw error;
77
+ });
78
+ if (info && (!info.isDirectory() || info.isSymbolicLink()))
79
+ throw new Error('La carpeta de instrucciones o registro no es normal.');
80
+ if (!info && create) await fs.mkdir(directory, { recursive: true, mode: 0o700 });
81
+ }
82
+ async function proof(home) {
83
+ await checkParent(path.dirname(proofPath(home)));
84
+ const text = await readText(proofPath(home));
85
+ if (text === null) return {};
86
+ const records = JSON.parse(text);
87
+ if (!records || typeof records !== 'object' || Array.isArray(records))
88
+ throw new Error('Registro de instrucciones inválido.');
89
+ return records;
90
+ }
91
+ function blockOf(text) {
92
+ const a = text?.indexOf(START) ?? -1,
93
+ b = text?.indexOf(END) ?? -1;
94
+ if (a < 0 || b <= a || text.indexOf(START, a + START.length) >= 0 || text.indexOf(END, b + END.length) >= 0)
95
+ return null;
96
+ return text.slice(a, b + END.length);
97
+ }
98
+ async function writeAtomic(target, text, lock) {
99
+ await lock.assertHeld();
100
+ const stat = await fs.lstat(target).catch((error) => {
101
+ if (error.code === 'ENOENT') return null;
102
+ throw error;
103
+ });
104
+ const tmp = target + '.' + randomUUID();
105
+ await fs.writeFile(tmp, text, { flag: 'wx', mode: stat?.mode ?? 0o600 });
106
+ try {
107
+ await lock.assertHeld();
108
+ await fs.rename(tmp, target);
109
+ } finally {
110
+ await fs.rm(tmp, { force: true });
111
+ }
112
+ }
113
+ export async function planReviewInstructions(options = {}) {
114
+ const owned = await proof(options.homeDirectory),
115
+ items = [];
116
+ for (const item of targets(options)) {
117
+ let before;
118
+ try {
119
+ await checkParent(path.dirname(item.target));
120
+ before = await readText(item.target);
121
+ } catch {
122
+ items.push({ ...item, state: 'conflict' });
123
+ continue;
124
+ }
125
+ const block = blockOf(before),
126
+ record = owned[item.target];
127
+ if ((before?.includes(START) && !block) || (block && record?.blockHash !== hash(block))) {
128
+ items.push({ ...item, state: 'conflict' });
129
+ continue;
130
+ }
131
+ const prefix = before === null ? '' : before.endsWith('\n') ? before + '\n' : before + '\n\n';
132
+ const after = block ? before.replace(block, BODY) : prefix + BODY + '\n';
133
+ items.push({
134
+ ...item,
135
+ before,
136
+ after,
137
+ state: after === before ? 'ready' : 'pending',
138
+ original: record ? record.original : before,
139
+ created: record ? record.created : before === null,
140
+ });
141
+ }
142
+ return { homeDirectory: options.homeDirectory, items };
143
+ }
144
+ export async function applyReviewInstructions(plan, lock) {
145
+ const owned = await proof(plan.homeDirectory),
146
+ pending = plan.items.filter((item) => item.state === 'pending');
147
+ for (const item of pending) {
148
+ await checkParent(path.dirname(item.target));
149
+ if ((await readText(item.target)) !== item.before)
150
+ throw new Error(`${item.name} cambió mientras confirmabas.`);
151
+ owned[item.target] = {
152
+ client: item.client,
153
+ blockHash: hash(BODY),
154
+ original: item.original,
155
+ created: item.created,
156
+ };
157
+ }
158
+ if (!pending.length) return;
159
+ await lock.assertHeld();
160
+ await checkParent(path.dirname(proofPath(plan.homeDirectory)), true);
161
+ // Record ownership before publishing blocks so an interrupted install can be resumed.
162
+ await writeAtomic(proofPath(plan.homeDirectory), JSON.stringify(owned), lock);
163
+ for (const item of pending) {
164
+ await lock.assertHeld();
165
+ await checkParent(path.dirname(item.target), true);
166
+ if ((await readText(item.target)) !== item.before)
167
+ throw new Error(`${item.name} cambió mientras confirmabas.`);
168
+ await writeAtomic(item.target, item.after, lock);
169
+ }
170
+ }
171
+ export async function removeReviewInstructions(options, lock) {
172
+ const owned = await proof(options.homeDirectory);
173
+ for (const item of targets(options)) {
174
+ const entry = owned[item.target];
175
+ if (!entry) continue;
176
+ let text;
177
+ try {
178
+ await checkParent(path.dirname(item.target));
179
+ text = await readText(item.target);
180
+ } catch {
181
+ continue;
182
+ }
183
+ const block = blockOf(text);
184
+ if (!block || hash(block) !== entry.blockHash) continue;
185
+ await lock.assertHeld();
186
+ if ((await readText(item.target)) !== text) throw new Error(`${item.name} cambió durante la retirada.`);
187
+ const without = text.replace(block, '');
188
+ if (entry.created && !without.trim()) await fs.unlink(item.target);
189
+ else if (
190
+ text ===
191
+ (entry.original ?? '') + ((entry.original ?? '').endsWith('\n') ? '\n' : '\n\n') + block + '\n'
192
+ )
193
+ await writeAtomic(item.target, entry.original ?? '', lock);
194
+ else await writeAtomic(item.target, without, lock);
195
+ delete owned[item.target];
196
+ }
197
+ if (!Object.keys(owned).length) {
198
+ await lock.assertHeld();
199
+ await fs.rm(proofPath(options.homeDirectory), { force: true });
200
+ } else await writeAtomic(proofPath(options.homeDirectory), JSON.stringify(owned), lock);
201
+ }
202
+ export async function ownedReviewInstructions(options) {
203
+ const owned = await proof(options.homeDirectory),
204
+ result = [];
205
+ for (const item of targets(options)) {
206
+ const text = await readText(item.target).catch(() => null),
207
+ block = blockOf(text);
208
+ if (block && owned[item.target]?.blockHash === hash(block)) result.push(item.name);
209
+ }
210
+ return result;
211
+ }
@@ -0,0 +1,35 @@
1
+ import path from 'node:path';
2
+ const CLIENTS = ['claude', 'codex', 'antigravity'];
3
+ export const RECOMMENDED_REVIEWERS = Object.freeze({
4
+ claude: 'codex',
5
+ codex: 'claude',
6
+ antigravity: 'claude',
7
+ });
8
+ export function selectReviewer({ principal, available, certified, pending = [], sessionChoice, requested }) {
9
+ if (!CLIENTS.includes(principal)) throw new Error('IA principal inválida.');
10
+ const options = CLIENTS.filter(
11
+ (ia) => ia !== principal && available.includes(ia) && (certified.includes(ia) || pending.includes(ia)),
12
+ );
13
+ const recommended = options.includes(RECOMMENDED_REVIEWERS[principal])
14
+ ? RECOMMENDED_REVIEWERS[principal]
15
+ : (options[0] ?? null);
16
+ const preferred = requested ?? sessionChoice;
17
+ const selected = options.includes(preferred) ? preferred : options.length === 1 ? options[0] : null;
18
+ return {
19
+ options,
20
+ pending: options.filter((ia) => !certified.includes(ia) && pending.includes(ia)),
21
+ recommended,
22
+ selected,
23
+ ask: options.length > 1 && !selected,
24
+ unavailable: preferred && !options.includes(preferred) ? preferred : null,
25
+ };
26
+ }
27
+ export function shouldAutoReview({ changedFiles = [], withoutReview = false, alreadyReviewed = false }) {
28
+ if (withoutReview || alreadyReviewed) return false;
29
+ return changedFiles.some(
30
+ (file) =>
31
+ !['.md', '.mdx', '.txt', '.rst', '.adoc', '.pdf', '.png', '.jpg', '.jpeg', '.svg'].includes(
32
+ path.extname(file).toLowerCase(),
33
+ ) && !/^(readme|license|licence|notice|changelog)$/iu.test(path.basename(file)),
34
+ );
35
+ }