@dsalazardev/ai-hub-cli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +64 -4
  2. package/dist/backup/snapshots.js +12 -0
  3. package/dist/catalog.d.ts +8 -1
  4. package/dist/catalog.js +38 -9
  5. package/dist/cli/args.js +1 -0
  6. package/dist/cli/doctor.js +20 -11
  7. package/dist/cli/maintenance.js +21 -39
  8. package/dist/cli/memory.js +4 -0
  9. package/dist/cli/onboarding.d.ts +13 -0
  10. package/dist/cli/onboarding.js +152 -14
  11. package/dist/cli/run.d.ts +3 -1
  12. package/dist/cli/run.js +5 -4
  13. package/dist/cli/types.d.ts +3 -0
  14. package/dist/engine/engine.d.ts +19 -1
  15. package/dist/engine/engine.js +52 -0
  16. package/dist/engine/gates.d.ts +16 -0
  17. package/dist/engine/gates.js +102 -1
  18. package/dist/harness/memory/ledger.d.ts +6 -0
  19. package/dist/harness/memory/ledger.js +44 -2
  20. package/dist/manifest/manifest.d.ts +3 -1
  21. package/dist/manifest/manifest.js +16 -10
  22. package/dist/presets/materialize.d.ts +5 -0
  23. package/dist/presets/materialize.js +30 -0
  24. package/dist/speckit/binding.d.ts +15 -0
  25. package/dist/speckit/binding.js +66 -0
  26. package/dist/speckit/commands.d.ts +64 -0
  27. package/dist/speckit/commands.js +171 -0
  28. package/dist/speckit/doctor.d.ts +17 -0
  29. package/dist/speckit/doctor.js +119 -0
  30. package/dist/speckit/runner.d.ts +19 -0
  31. package/dist/speckit/runner.js +22 -0
  32. package/dist/speckit/seal.d.ts +12 -0
  33. package/dist/speckit/seal.js +47 -0
  34. package/dist/speckit/settings.d.ts +36 -0
  35. package/dist/speckit/settings.js +75 -0
  36. package/dist/speckit/state.d.ts +59 -0
  37. package/dist/speckit/state.js +279 -0
  38. package/dist/speckit/version.d.ts +8 -0
  39. package/dist/speckit/version.js +53 -0
  40. package/dist/sync/config.js +3 -2
  41. package/dist/sync/render.js +30 -0
  42. package/dist/sync/sync.d.ts +4 -0
  43. package/dist/sync/sync.js +48 -5
  44. package/dist/sync/targets.d.ts +13 -0
  45. package/dist/sync/targets.js +5 -0
  46. package/engine/engine.yaml +65 -31
  47. package/manifest.json +20 -48
  48. package/package.json +1 -1
  49. package/schema/engine.schema.json +22 -3
  50. package/schema/manifest.schema.json +10 -0
  51. package/skills/shared/analyze/SKILL.md +2 -1
  52. package/skills/shared/clarify/SKILL.md +2 -1
  53. package/skills/shared/implement/SKILL.md +2 -1
  54. package/skills/shared/plan/SKILL.md +2 -1
  55. package/skills/shared/review/SKILL.md +2 -1
  56. package/skills/shared/sdd/SKILL.md +2 -1
  57. package/skills/shared/sdd-init/SKILL.md +2 -1
  58. package/skills/shared/ship/SKILL.md +2 -1
  59. package/skills/shared/specify/SKILL.md +2 -1
  60. package/skills/shared/tasks/SKILL.md +2 -1
  61. package/skills/shared/verify/SKILL.md +2 -1
  62. package/skills/shared/speckit-analyze/SKILL.md +0 -35
  63. package/skills/shared/speckit-assess-decide/SKILL.md +0 -36
  64. package/skills/shared/speckit-assess-define/SKILL.md +0 -36
  65. package/skills/shared/speckit-assess-intake/SKILL.md +0 -38
  66. package/skills/shared/speckit-assess-research/SKILL.md +0 -36
  67. package/skills/shared/speckit-assess-shape/SKILL.md +0 -36
  68. package/skills/shared/speckit-bug-assess/SKILL.md +0 -38
  69. package/skills/shared/speckit-bug-fix/SKILL.md +0 -37
  70. package/skills/shared/speckit-bug-test/SKILL.md +0 -37
  71. package/skills/shared/speckit-checklist/SKILL.md +0 -35
  72. package/skills/shared/speckit-clarify/SKILL.md +0 -32
  73. package/skills/shared/speckit-constitution/SKILL.md +0 -35
  74. package/skills/shared/speckit-converge/SKILL.md +0 -36
  75. package/skills/shared/speckit-implement/SKILL.md +0 -32
  76. package/skills/shared/speckit-plan/SKILL.md +0 -37
  77. package/skills/shared/speckit-specify/SKILL.md +0 -36
  78. package/skills/shared/speckit-tasks/SKILL.md +0 -34
@@ -4,7 +4,7 @@ import { stringify } from 'yaml';
4
4
  import { createBackup } from '../backup/snapshots.js';
5
5
  import { readAiHubRc, writeAiHubRc } from '../paths.js';
6
6
  import { detectPreset } from '../presets/detect.js';
7
- import { materializeCatalog, materializePreset, materializeUniversalRules, } from '../presets/materialize.js';
7
+ import { materializeCatalog, materializePreset, materializeUniversalRules, removeOwned, } from '../presets/materialize.js';
8
8
  import { resolvePreset } from '../presets/resolve.js';
9
9
  import { listPresetIds } from '../presets/schema.js';
10
10
  import { loadManifestContext, writeInventory } from '../manifest/manifest.js';
@@ -15,6 +15,9 @@ import { sha256 } from '../sync/lock.js';
15
15
  import { isEngineCheckout } from '../source-checkout.js';
16
16
  import { ENGINE_AGENTS_DIR, ENGINE_RULES_DIR, catalogIndex, catalogSeal, expectedRuleIds, hasFullDimension, listCatalogAgents, listCatalogSkills, } from '../catalog.js';
17
17
  import { loadEngine } from '../engine/engine.js';
18
+ import { probeEnvironment, runSpeckitSequence, speckitPreflight, } from '../speckit/commands.js';
19
+ import { effectiveSpeckit } from '../speckit/settings.js';
20
+ import { checkExtensions, checkIntegrations, readSpeckitState } from '../speckit/state.js';
18
21
  import { flagList, flagString } from './args.js';
19
22
  const RC_NAME = '.ai-hubrc.json';
20
23
  export async function cliVersion() {
@@ -22,6 +25,87 @@ export async function cliVersion() {
22
25
  const pkg = JSON.parse(raw);
23
26
  return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
24
27
  }
28
+ function speckitPlan(settings) {
29
+ return {
30
+ managed: settings.managed,
31
+ package: settings.package,
32
+ version: settings.version,
33
+ excludeNewer: settings.excludeNewer,
34
+ script: settings.script,
35
+ defaultIntegration: settings.defaultIntegration,
36
+ integrations: settings.integrations,
37
+ extensions: settings.extensions,
38
+ };
39
+ }
40
+ /** Bloque `speckit` a escribir: conserva las restricciones del proyecto y fija `managed` y el script explícito. */
41
+ function speckitBlock(existing, managed, script) {
42
+ return { ...(existing ?? {}), managed, ...(script !== undefined ? { script } : {}) };
43
+ }
44
+ /**
45
+ * Lee `--script <sh|ps>` (design D3): valida antes de cualquier sondeo y nombra el comando concreto.
46
+ * Devuelve `undefined` cuando la bandera no se usó y `null` cuando es inválida (ya reportado).
47
+ */
48
+ function parseScriptFlag(args, ctx, command) {
49
+ if (!('script' in args.flags)) {
50
+ return undefined;
51
+ }
52
+ const value = flagString(args, 'script');
53
+ if (value === 'sh' || value === 'ps') {
54
+ return value;
55
+ }
56
+ ctx.io.err(`--script acepta sh o ps${value === null ? ' y necesita un valor' : ` (recibido: ${value})`}; ` +
57
+ `ejecuta ai-hub ${command} --script sh o ai-hub ${command} --script ps`);
58
+ return null;
59
+ }
60
+ function speckitSettingsFor(targets, block, engine) {
61
+ return effectiveSpeckit({ targets, speckit: block }, engine);
62
+ }
63
+ async function environmentFor(settings, ctx) {
64
+ if (!settings.managed) {
65
+ return { skipped: 'Spec Kit no gestionado (--no-speckit)' };
66
+ }
67
+ return probeEnvironment(settings, ctx.exec, ctx.root);
68
+ }
69
+ /** Preflight de Spec Kit; devuelve `false` y explica el motivo si hay que salir con 2. */
70
+ async function preflightOrReport(settings, ctx, command) {
71
+ const preflight = await speckitPreflight(settings, ctx.exec, ctx.root, command);
72
+ if (!preflight.ok) {
73
+ ctx.io.err('preflight de Spec Kit fallido; no se escribió nada:');
74
+ for (const problem of preflight.problems) {
75
+ ctx.io.err(` - ${problem}`);
76
+ }
77
+ }
78
+ return preflight.ok;
79
+ }
80
+ /** Ejecuta la secuencia de Spec Kit; devuelve `false` y explica cómo restaurar si falla. */
81
+ async function installSpeckit(settings, ctx, backupId) {
82
+ const result = await runSpeckitSequence(settings, ctx.exec, ctx.root);
83
+ const problems = [];
84
+ if (!result.ok) {
85
+ problems.push(result.failure ?? 'fallo desconocido');
86
+ }
87
+ else {
88
+ // Paso 9d: la secuencia solo cuenta si deja las integraciones y extensiones esperadas.
89
+ const state = await readSpeckitState(ctx.root);
90
+ problems.push(...checkIntegrations(settings, state), ...(await checkExtensions(ctx.root, settings, state)));
91
+ }
92
+ if (problems.length > 0) {
93
+ ctx.io.err('Spec Kit no quedó instalado como se esperaba:');
94
+ for (const problem of problems) {
95
+ ctx.io.err(` - ${problem}`);
96
+ }
97
+ if (backupId !== null) {
98
+ ctx.io.err(`restaura el estado previo con: ai-hub restore ${backupId}`);
99
+ }
100
+ return false;
101
+ }
102
+ return true;
103
+ }
104
+ function speckitSummary(settings) {
105
+ return settings.managed
106
+ ? `Spec Kit ${settings.version} · script ${settings.script} · integraciones ${settings.integrations.join(', ')}`
107
+ : 'Spec Kit no gestionado (--no-speckit): el arnés queda degradado; ejecuta ai-hub install para activarlo';
108
+ }
25
109
  function projectId(root) {
26
110
  const base = path.basename(path.resolve(root)).toLowerCase();
27
111
  const id = base.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
@@ -78,13 +162,14 @@ async function buildSelection(args, preset, engine, existing) {
78
162
  const exclusions = parseExclusions(args);
79
163
  const hasExclusions = exclusions.skills.size > 0 || exclusions.agents.size > 0 || exclusions.rules.size > 0;
80
164
  if (existing && !fullFlag && !presetOnly && !hasExclusions) {
165
+ const retired = new Set(await retiredCatalogSkills(existing.skills.publish));
81
166
  return {
82
167
  modes: {
83
168
  skills: existing.skills.selection ?? 'preset',
84
169
  agents: existing.agents.selection ?? 'preset',
85
170
  rules: existing.rules.selection ?? 'preset',
86
171
  },
87
- skills: [...existing.skills.publish],
172
+ skills: existing.skills.publish.filter((id) => !retired.has(id)),
88
173
  agents: [...existing.agents.publish],
89
174
  rules: [...existing.rules.publish],
90
175
  };
@@ -125,7 +210,7 @@ async function buildSelection(args, preset, engine, existing) {
125
210
  rules: [...new Set(filterRules(rulesBase, exclusions.rules))].sort(),
126
211
  };
127
212
  }
128
- function manifestFromSelection(id, preset, targets, profilePackageManager, guardMode, includeMcp, selection) {
213
+ function manifestFromSelection(id, preset, targets, profilePackageManager, guardMode, includeMcp, selection, speckit) {
129
214
  const manifest = {
130
215
  version: 2,
131
216
  project: { id, name: id },
@@ -150,6 +235,7 @@ function manifestFromSelection(id, preset, targets, profilePackageManager, guard
150
235
  agents: { source: '.ai-hub/agents', publish: selection.agents, selection: selection.modes.agents },
151
236
  rules: { source: '.ai-hub/rules', publish: selection.rules, selection: selection.modes.rules },
152
237
  ...(includeMcp ? { mcp: { source: 'mcp-manifest.yaml' } } : {}),
238
+ speckit,
153
239
  };
154
240
  if (hasFullDimension(manifest)) {
155
241
  manifest.catalog = catalogSeal(manifest);
@@ -180,8 +266,9 @@ async function materializeSelection(root, preset, selection) {
180
266
  }
181
267
  async function buildAssetPlan(selection, engine, targets, includeMcp) {
182
268
  const index = await catalogIndex();
183
- const engineSkills = engine.commands.map((command) => command.skill);
184
- const allSkills = [...new Set([...engineSkills, ...selection.skills])];
269
+ // Los 17 comandos los proyecta Spec Kit; AI-Hub solo proyecta las skills de proyecto.
270
+ void engine;
271
+ const allSkills = [...new Set(selection.skills)];
185
272
  const projectSkillFiles = selection.skills.reduce((total, id) => total + (index.skillFiles[id]?.length ?? 0), 0);
186
273
  const skillFiles = allSkills.reduce((total, id) => total + (index.skillFiles[id]?.length ?? 0), 0);
187
274
  let destinations = 1; // opencode.json
@@ -191,7 +278,7 @@ async function buildAssetPlan(selection, engine, targets, includeMcp) {
191
278
  if (target.agents) {
192
279
  destinations += selection.agents.length;
193
280
  }
194
- destinations += selection.rules.length + 1; // reglas materializadas + preflight
281
+ destinations += selection.rules.length + 2; // reglas materializadas + preflight + regla de enlace de Spec Kit
195
282
  }
196
283
  if (includeMcp) {
197
284
  destinations += 3;
@@ -288,6 +375,10 @@ async function resolveSelection(args, ctx) {
288
375
  };
289
376
  }
290
377
  export async function cmdInit(args, ctx) {
378
+ const scriptFlag = parseScriptFlag(args, ctx, 'init');
379
+ if (scriptFlag === null) {
380
+ return 2;
381
+ }
291
382
  const sourceCheckout = await isEngineCheckout(ctx.root);
292
383
  let selection;
293
384
  try {
@@ -323,7 +414,14 @@ export async function cmdInit(args, ctx) {
323
414
  }
324
415
  const includeMcp = await exists(path.join(ctx.root, 'mcp-manifest.yaml'));
325
416
  const assets = await buildAssetPlan(spec, engine, selection.targets, includeMcp);
417
+ const speckit = speckitBlock(existing?.speckit, !args.flags['no-speckit'], scriptFlag);
418
+ const speckitSettings = speckitSettingsFor(selection.targets, speckit, engine);
419
+ // El taller nunca ejecuta Spec Kit: su .specify/ es del mantenedor.
420
+ const runSpeckit = speckitSettings.managed && !sourceCheckout;
326
421
  const write = [RC_NAME, 'manifest.yaml', '.gitignore', '.ai-hub/'];
422
+ if (runSpeckit) {
423
+ write.push('.specify/');
424
+ }
327
425
  if (selection.migrate) {
328
426
  write.push('.planning/state.json');
329
427
  }
@@ -333,28 +431,38 @@ export async function cmdInit(args, ctx) {
333
431
  preset: selection.preset.id,
334
432
  targets: selection.targets,
335
433
  ...assets,
336
- skillCount: 17 + spec.skills.length,
434
+ skillCount: spec.skills.length,
435
+ speckitCommands: engine.commands.length,
337
436
  agentCount: spec.agents.length,
338
437
  ruleCount: spec.rules.length + 1,
339
438
  mcp: includeMcp,
340
439
  migrate: selection.migrate,
341
440
  write,
441
+ speckit: speckitPlan(speckitSettings),
342
442
  };
343
443
  const plan = { ...planBody, hash: sha256(JSON.stringify(planBody)) };
344
444
  if (!args.flags.yes || args.flags['dry-run']) {
345
- ctx.io.out(JSON.stringify(plan, null, 2));
445
+ const environment = await environmentFor(speckitSettings, ctx);
446
+ ctx.io.out(JSON.stringify({ ...plan, environment }, null, 2));
346
447
  if (!args.flags['dry-run']) {
347
448
  ctx.io.out('Nada escrito. Repite con --yes para aplicar este plan.');
348
449
  }
349
450
  return 0;
350
451
  }
351
- if (existing) {
352
- await createBackup(ctx.root, 'init');
452
+ if (runSpeckit && !(await preflightOrReport(speckitSettings, ctx, 'init'))) {
453
+ return 2;
353
454
  }
455
+ // Backup restaurable antes de que Spec Kit toque un proyecto existente (constitución incluida).
456
+ const backupId = existing || (await exists(path.join(ctx.root, '.specify')))
457
+ ? (await createBackup(ctx.root, 'init')).id
458
+ : null;
354
459
  const materialized = await materializeSelection(ctx.root, selection.preset, spec);
355
- const manifest = manifestFromSelection(id, selection.preset, selection.targets, selection.profilePackageManager, rc.guardMode ?? 'shadow', includeMcp, spec);
460
+ const manifest = manifestFromSelection(id, selection.preset, selection.targets, selection.profilePackageManager, rc.guardMode ?? 'shadow', includeMcp, spec, speckit);
356
461
  await writeManifest(ctx.root, manifest);
357
462
  await writeInventory(ctx.root);
463
+ if (runSpeckit && !(await installSpeckit(speckitSettings, ctx, backupId))) {
464
+ return 1;
465
+ }
358
466
  const sync = await runSync(ctx.root, { check: false });
359
467
  const existingRc = readAiHubRc(ctx.cwd);
360
468
  if (!existingRc || existingRc.version === 1 || args.flags.force) {
@@ -381,10 +489,11 @@ export async function cmdInit(args, ctx) {
381
489
  const hook = await installPostMergeHook(ctx.root);
382
490
  ctx.io.out(`hooks: ${hook.installed ? 'post-merge instalado' : hook.reason}`);
383
491
  }
384
- ctx.io.out(`init: ${selection.preset.id} · skills=${spec.modes.skills} agents=${spec.modes.agents} rules=${spec.modes.rules} · ${spec.skills.length + 17} skills · ${spec.agents.length} agentes · ${spec.rules.length + 1} reglas · ${assets.destinations} destinos · ${sync.written.length} sincronizados · ${materialized.written.length} nuevos, ${materialized.kept.length} conservados, ${materialized.collisions.length} colisiones`);
492
+ ctx.io.out(`init: ${selection.preset.id} · skills=${spec.modes.skills} agents=${spec.modes.agents} rules=${spec.modes.rules} · ${spec.skills.length} skills de proyecto + ${engine.commands.length} comandos Spec Kit · ${spec.agents.length} agentes · ${spec.rules.length + 1} reglas · ${assets.destinations} destinos · ${sync.written.length} sincronizados · ${materialized.written.length} nuevos, ${materialized.kept.length} conservados, ${materialized.collisions.length} colisiones`);
385
493
  if (materialized.collisions.length > 0) {
386
494
  ctx.io.err(`colisiones (no se sobrescribieron): ${materialized.collisions.join(', ')}`);
387
495
  }
496
+ ctx.io.out(speckitSummary(speckitSettings));
388
497
  ctx.io.out(`plan: ${plan.hash}`);
389
498
  return 0;
390
499
  }
@@ -435,14 +544,24 @@ async function selectionForInstall(args, manifest, preset, engine) {
435
544
  : modes.rules === 'preset'
436
545
  ? expectedRuleIds('preset', preset)
437
546
  : [...manifest.rules.publish];
547
+ const retired = new Set(await retiredCatalogSkills(skills));
438
548
  return {
439
549
  modes,
440
- skills: [...new Set(skills.filter((id) => !exclusions.skills.has(id)))].sort(),
550
+ skills: [...new Set(skills.filter((id) => !exclusions.skills.has(id) && !retired.has(id)))].sort(),
441
551
  agents: [...new Set(agents.filter((id) => !exclusions.agents.has(id)))].sort(),
442
552
  rules: [...new Set(filterRules(rulesBase, exclusions.rules))].sort(),
443
553
  };
444
554
  }
555
+ /** Ids de `ids` que el catálogo publica con `runtime: false` (no instalables). */
556
+ async function retiredCatalogSkills(ids) {
557
+ const retired = new Set((await catalogIndex()).retiredSkills);
558
+ return ids.filter((id) => retired.has(id));
559
+ }
445
560
  export async function cmdInstall(args, ctx) {
561
+ const scriptFlag = parseScriptFlag(args, ctx, 'install');
562
+ if (scriptFlag === null) {
563
+ return 2;
564
+ }
446
565
  let context;
447
566
  try {
448
567
  context = await loadManifestContext(ctx.root);
@@ -460,6 +579,10 @@ export async function cmdInstall(args, ctx) {
460
579
  return 2;
461
580
  }
462
581
  const includeMcp = await exists(path.join(ctx.root, 'mcp-manifest.yaml'));
582
+ // install sin --no-speckit reactiva Spec Kit en un arnés degradado; con la bandera lo mantiene apagado.
583
+ const speckit = speckitBlock(context.manifest.speckit, !args.flags['no-speckit'], scriptFlag);
584
+ const speckitSettings = speckitSettingsFor(context.manifest.targets, speckit, context.engine);
585
+ const runSpeckit = speckitSettings.managed && !(await isEngineCheckout(ctx.root));
463
586
  if (args.flags['dry-run']) {
464
587
  const assets = await buildAssetPlan(spec, context.engine, context.manifest.targets, includeMcp);
465
588
  ctx.io.out(JSON.stringify({
@@ -472,10 +595,20 @@ export async function cmdInstall(args, ctx) {
472
595
  agents: spec.agents.length,
473
596
  rules: spec.rules.length + 1,
474
597
  destinations: assets.destinations,
598
+ speckit: speckitPlan(speckitSettings),
475
599
  }, null, 2));
476
600
  return 0;
477
601
  }
602
+ if (runSpeckit && !(await preflightOrReport(speckitSettings, ctx, 'install'))) {
603
+ return 2;
604
+ }
605
+ const backupId = runSpeckit ? (await createBackup(ctx.root, 'install')).id : null;
478
606
  const materialized = await materializeSelection(ctx.root, context.preset, spec);
607
+ // Migración: las skills `runtime: false` salen de .ai-hub/ si las escribió ai-hub;
608
+ // sync retira después sus copias de los destinos.
609
+ for (const id of await retiredCatalogSkills(context.manifest.skills.publish)) {
610
+ await removeOwned(ctx.root, path.join(context.manifest.skills.source, id), true);
611
+ }
479
612
  const next = {
480
613
  ...context.manifest,
481
614
  skills: { ...context.manifest.skills, publish: spec.skills, selection: spec.modes.skills },
@@ -494,7 +627,11 @@ export async function cmdInstall(args, ctx) {
494
627
  else {
495
628
  delete next.catalog;
496
629
  }
630
+ next.speckit = speckit;
497
631
  await writeManifest(ctx.root, next);
632
+ if (runSpeckit && !(await installSpeckit(speckitSettings, ctx, backupId))) {
633
+ return 1;
634
+ }
498
635
  const sync = await runSync(ctx.root, { check: false });
499
636
  await writeInventory(ctx.root);
500
637
  await ensureProjectIgnores(ctx.root);
@@ -502,10 +639,11 @@ export async function cmdInstall(args, ctx) {
502
639
  const hook = await installPostMergeHook(ctx.root);
503
640
  ctx.io.out(`hooks: ${hook.installed ? 'post-merge instalado' : hook.reason}`);
504
641
  }
505
- ctx.io.out(`install: skills=${spec.modes.skills} agents=${spec.modes.agents} rules=${spec.modes.rules} · ${spec.skills.length + 17} skills · ${spec.agents.length} agentes · ${spec.rules.length + 1} reglas · ${materialized.written.length} nuevos · ${materialized.kept.length} conservados · ${sync.written.length} sincronizados`);
642
+ ctx.io.out(`install: skills=${spec.modes.skills} agents=${spec.modes.agents} rules=${spec.modes.rules} · ${spec.skills.length} skills de proyecto + ${context.engine.commands.length} comandos Spec Kit · ${spec.agents.length} agentes · ${spec.rules.length + 1} reglas · ${materialized.written.length} nuevos · ${materialized.kept.length} conservados · ${sync.written.length} sincronizados`);
506
643
  if (!Object.values(spec.modes).includes('full')) {
507
644
  ctx.io.out('full: no aplica; usa install --full para expandir el catálogo');
508
645
  }
646
+ ctx.io.out(speckitSummary(speckitSettings));
509
647
  return 0;
510
648
  }
511
649
  export async function cmdRepair(args, ctx) {
package/dist/cli/run.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { CliContext, CliIO, CommandHandler } from './types.js';
2
+ import { type ProcessRunner } from '../speckit/runner.js';
2
3
  interface CommandSpec {
3
4
  summary: string;
4
5
  handler: CommandHandler;
@@ -6,10 +7,11 @@ interface CommandSpec {
6
7
  export declare const COMMANDS: Record<string, CommandSpec>;
7
8
  export declare const COMMAND_NAMES: string[];
8
9
  export declare function printHelp(io: CliIO): void;
9
- export declare function createContext(cwd: string, io: CliIO): Promise<CliContext>;
10
+ export declare function createContext(cwd: string, io: CliIO, exec?: ProcessRunner): Promise<CliContext>;
10
11
  export interface RunCliOptions {
11
12
  cwd?: string;
12
13
  io?: CliIO;
14
+ exec?: ProcessRunner;
13
15
  }
14
16
  export declare function runCli(argv: string[], options?: RunCliOptions): Promise<number>;
15
17
  export {};
package/dist/cli/run.js CHANGED
@@ -14,6 +14,7 @@ import { cmdWorktrees } from './worktrees.js';
14
14
  import { cliVersion } from './onboarding.js';
15
15
  import { sourceCheckout } from './source.js';
16
16
  import { processIO } from './types.js';
17
+ import { defaultRunner } from '../speckit/runner.js';
17
18
  function printLockCheck(result, ctx) {
18
19
  const header = `destinos esperados ${result.expectedCount} / actuales ${result.actualCount} · sources ${result.expectedSourceCount}/${result.actualSourceCount}`;
19
20
  const extraCategories = result.breakdown.other > 0 ? `, other ${result.breakdown.other}` : '';
@@ -136,14 +137,14 @@ export function printHelp(io) {
136
137
  io.out('Publicación: prepublishOnly ejecuta build, manifest:build y npm test (unit + integración).');
137
138
  io.out('Compatibilidad: verify, judge (fuera del catálogo).');
138
139
  }
139
- export async function createContext(cwd, io) {
140
+ export async function createContext(cwd, io, exec = defaultRunner) {
140
141
  try {
141
142
  const location = resolveAiHubDir(cwd);
142
- return { cwd, root: location.dir, source: location.source, io };
143
+ return { cwd, root: location.dir, source: location.source, io, exec };
143
144
  }
144
145
  catch (error) {
145
146
  if (error instanceof AiHubDirNotFoundError) {
146
- return { cwd, root: cwd, source: null, io };
147
+ return { cwd, root: cwd, source: null, io, exec };
147
148
  }
148
149
  throw error;
149
150
  }
@@ -152,7 +153,7 @@ export async function runCli(argv, options = {}) {
152
153
  const io = options.io ?? processIO;
153
154
  const cwd = options.cwd ?? process.cwd();
154
155
  const [command, ...rest] = argv;
155
- const ctx = await createContext(cwd, io);
156
+ const ctx = await createContext(cwd, io, options.exec);
156
157
  if (!command) {
157
158
  printHelp(io);
158
159
  return 2;
@@ -1,4 +1,5 @@
1
1
  import type { AiHubSource } from '../paths.js';
2
+ import type { ProcessRunner } from '../speckit/runner.js';
2
3
  export interface CliIO {
3
4
  out(message: string): void;
4
5
  err(message: string): void;
@@ -8,6 +9,8 @@ export interface CliContext {
8
9
  root: string;
9
10
  source: AiHubSource | null;
10
11
  io: CliIO;
12
+ /** Ejecutor de procesos externos (uvx, bash); los tests lo sustituyen. */
13
+ exec: ProcessRunner;
11
14
  }
12
15
  export interface ParsedArgs {
13
16
  positionals: string[];
@@ -28,15 +28,33 @@ export interface EngineCommand {
28
28
  exit: string[];
29
29
  role: string;
30
30
  co_roles?: string[];
31
- skill: string;
31
+ /** Id canónico del comando en Spec Kit, en notación con punto (`speckit.bug.assess`). */
32
+ speckit: string;
32
33
  }
34
+ export type SpeckitScript = 'sh' | 'ps';
35
+ /** Dependencia de Spec Kit fijada por el paquete; un proyecto solo puede restringirla. */
36
+ export interface EngineSpeckit {
37
+ package: string;
38
+ pin: string;
39
+ supported: string;
40
+ excludeNewer: string;
41
+ script: SpeckitScript;
42
+ extensions: Record<string, string>;
43
+ }
44
+ /** Extensiones de Spec Kit que proveen los 8 comandos de assessment y bug fix. */
45
+ export declare const REQUIRED_SPECKIT_EXTENSIONS: readonly ["assess", "bug"];
33
46
  export interface EngineManifest {
34
47
  version: 2;
35
48
  roles: EngineRole[];
36
49
  commands: EngineCommand[];
50
+ /** Efecto de cada estado de salida sobre las etiquetas; tabla única proyectada en la regla de enlace. */
51
+ exit_effects: Record<string, string>;
37
52
  labels: string[];
38
53
  targets: string[];
54
+ speckit: EngineSpeckit;
39
55
  }
56
+ /** Estados de salida que el vocabulario normalizado de Spec Kit retiró. */
57
+ export declare const RETIRED_EXIT_STATES: readonly ["ROOT_CAUSE", "NEEDS_DATA", "FIXED", "RESOLVED", "OPEN", "DRAFT", "READY"];
40
58
  export declare class EngineError extends Error {
41
59
  readonly errors: string[];
42
60
  constructor(errors: string[]);
@@ -3,12 +3,17 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { Ajv } from 'ajv';
5
5
  import { parse } from 'yaml';
6
+ import { satisfiesRange } from '../speckit/version.js';
6
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
8
  const ENGINE_PATH = path.join(__dirname, '..', '..', 'engine', 'engine.yaml');
8
9
  const SCHEMA_PATH = path.join(__dirname, '..', '..', 'schema', 'engine.schema.json');
9
10
  export const ENGINE_SKILLS_DIR = path.join(__dirname, '..', '..', 'skills', 'shared');
10
11
  export const ENGINE_COUNTS = { roles: 8, commands: 17, labels: 7 };
11
12
  export const ENGINE_LANES = { assessment: 5, feature: 9, bug: 3 };
13
+ /** Extensiones de Spec Kit que proveen los 8 comandos de assessment y bug fix. */
14
+ export const REQUIRED_SPECKIT_EXTENSIONS = ['assess', 'bug'];
15
+ /** Estados de salida que el vocabulario normalizado de Spec Kit retiró. */
16
+ export const RETIRED_EXIT_STATES = ['ROOT_CAUSE', 'NEEDS_DATA', 'FIXED', 'RESOLVED', 'OPEN', 'DRAFT', 'READY'];
12
17
  export class EngineError extends Error {
13
18
  errors;
14
19
  constructor(errors) {
@@ -104,6 +109,53 @@ export function crossValidate(engine) {
104
109
  errors.push(`lane "${lane}" debe tener ${count} comandos (tiene ${actual})`);
105
110
  }
106
111
  }
112
+ for (const id of findDuplicateIds(engine.commands.map((command) => command.speckit))) {
113
+ errors.push(`id de Spec Kit duplicado: ${id}`);
114
+ }
115
+ const retired = new Set(RETIRED_EXIT_STATES);
116
+ const states = new Set();
117
+ for (const command of engine.commands) {
118
+ for (const state of command.exit) {
119
+ states.add(state);
120
+ if (retired.has(state)) {
121
+ errors.push(`commands "${command.id}".exit usa el estado retirado ${state}`);
122
+ }
123
+ }
124
+ }
125
+ const effects = engine.exit_effects ?? {};
126
+ for (const state of [...states].sort()) {
127
+ if (!(state in effects)) {
128
+ errors.push(`exit_effects no define el efecto de ${state}`);
129
+ }
130
+ }
131
+ for (const state of Object.keys(effects).sort()) {
132
+ if (!states.has(state)) {
133
+ errors.push(`exit_effects.${state} no corresponde a ningún estado de salida`);
134
+ }
135
+ }
136
+ errors.push(...validateEngineSpeckit(engine.speckit));
137
+ return errors;
138
+ }
139
+ function validateEngineSpeckit(speckit) {
140
+ if (speckit === undefined) {
141
+ return ['falta el bloque speckit'];
142
+ }
143
+ const errors = [];
144
+ for (const field of ['package', 'pin', 'supported', 'excludeNewer', 'script']) {
145
+ if (typeof speckit[field] !== 'string' || speckit[field] === '') {
146
+ errors.push(`falta speckit.${field}`);
147
+ }
148
+ }
149
+ for (const extension of REQUIRED_SPECKIT_EXTENSIONS) {
150
+ if (typeof speckit.extensions?.[extension] !== 'string') {
151
+ errors.push(`falta speckit.extensions.${extension}`);
152
+ }
153
+ }
154
+ if (typeof speckit.pin === 'string' &&
155
+ typeof speckit.supported === 'string' &&
156
+ !satisfiesRange(speckit.pin, speckit.supported)) {
157
+ errors.push(`speckit.pin ${speckit.pin} está fuera de speckit.supported "${speckit.supported}"`);
158
+ }
107
159
  return errors;
108
160
  }
109
161
  export function commandById(engine, id) {
@@ -1,2 +1,18 @@
1
1
  export declare function scanLegacyArtifacts(root: string): Promise<string[]>;
2
2
  export declare function scanCoupling(root: string, terms: string[]): Promise<string[]>;
3
+ /** Prefijos que Spec Kit reserva en los directorios de comandos de cada destino. */
4
+ export declare const SPECKIT_RESERVED_PREFIXES: readonly ["speckit-", "speckit."];
5
+ /**
6
+ * Gate del catálogo publicado: ninguna skill, agente o regla puede proyectarse con un nombre
7
+ * del espacio reservado de Spec Kit (`speckit-*` / `speckit.*`).
8
+ */
9
+ export declare function scanReservedNamespace(dirs?: {
10
+ skills: string;
11
+ agents: string;
12
+ rules: string;
13
+ }): Promise<string[]>;
14
+ /**
15
+ * Gate del vocabulario de estados retirado (`ROOT_CAUSE`, `FIXED`, `READY`…): devuelve
16
+ * `ruta:línea vocabulario obsoleto "TOKEN"` relativo a `root`.
17
+ */
18
+ export declare function scanRetiredStates(files: string[] | undefined, root: string): Promise<string[]>;
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { isEngineCheckout } from '../source-checkout.js';
4
- import { ENGINE_AGENTS_DIR, catalogIndex } from '../catalog.js';
4
+ import { ENGINE_AGENTS_DIR, ENGINE_RULES_DIR, catalogIndex } from '../catalog.js';
5
5
  import { ENGINE_SKILLS_DIR } from './engine.js';
6
6
  const LEGACY_SOURCE_DIRS = ['core/presets', 'core/skills', 'core/agents'];
7
7
  const LEGACY_TARGET_DIRS = [
@@ -129,3 +129,104 @@ export async function scanCoupling(root, terms) {
129
129
  }
130
130
  return findings.sort();
131
131
  }
132
+ /** Prefijos que Spec Kit reserva en los directorios de comandos de cada destino. */
133
+ export const SPECKIT_RESERVED_PREFIXES = ['speckit-', 'speckit.'];
134
+ function reservedName(name) {
135
+ return SPECKIT_RESERVED_PREFIXES.some((prefix) => name.startsWith(prefix));
136
+ }
137
+ /**
138
+ * Gate del catálogo publicado: ninguna skill, agente o regla puede proyectarse con un nombre
139
+ * del espacio reservado de Spec Kit (`speckit-*` / `speckit.*`).
140
+ */
141
+ export async function scanReservedNamespace(dirs = {
142
+ skills: ENGINE_SKILLS_DIR,
143
+ agents: ENGINE_AGENTS_DIR,
144
+ rules: ENGINE_RULES_DIR,
145
+ }) {
146
+ const findings = [];
147
+ for (const entry of await fs.readdir(dirs.skills, { withFileTypes: true }).catch(() => [])) {
148
+ if (entry.isDirectory() && reservedName(entry.name)) {
149
+ findings.push(`skills/shared/${entry.name} (espacio reservado de Spec Kit)`);
150
+ }
151
+ }
152
+ for (const [kind, dir] of [['agents', dirs.agents], ['rules', dirs.rules]]) {
153
+ for (const name of await listNames(dir)) {
154
+ if (reservedName(path.basename(name))) {
155
+ findings.push(`${kind}/${name} (espacio reservado de Spec Kit)`);
156
+ }
157
+ }
158
+ }
159
+ return findings.sort();
160
+ }
161
+ async function listNames(dir, prefix = '') {
162
+ const names = [];
163
+ for (const entry of await fs.readdir(dir, { withFileTypes: true }).catch(() => [])) {
164
+ const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
165
+ if (entry.isDirectory()) {
166
+ names.push(...(await listNames(path.join(dir, entry.name), rel)));
167
+ }
168
+ else if (entry.isFile()) {
169
+ names.push(rel);
170
+ }
171
+ }
172
+ return names;
173
+ }
174
+ /** Estados retirados que solo existían como estado de salida: se marcan en cualquier contexto. */
175
+ const RETIRED_ALWAYS = ['ROOT_CAUSE', 'NEEDS_DATA'];
176
+ /** Estados retirados que también son palabras corrientes: se marcan solo usados como estado. */
177
+ const RETIRED_IN_STATE_CONTEXT = ['FIXED', 'RESOLVED', 'OPEN', 'DRAFT', 'READY'];
178
+ const STATE_CONTEXT = /\b(estados?|exit|exits|termina(n)? en|ends? in|outcome|veredicto|verdict)\b/i;
179
+ function retiredInLine(line) {
180
+ const found = [];
181
+ const words = new Set(line.split(/[^A-Za-z0-9_-]+/));
182
+ for (const token of RETIRED_ALWAYS) {
183
+ if (words.has(token)) {
184
+ found.push(token);
185
+ }
186
+ }
187
+ for (const token of RETIRED_IN_STATE_CONTEXT) {
188
+ const quoted = line.includes(`\`${token}\``);
189
+ const inContext = STATE_CONTEXT.test(line) && words.has(token);
190
+ if (quoted || inContext) {
191
+ found.push(token);
192
+ }
193
+ }
194
+ return found;
195
+ }
196
+ /** Archivos que el gate revisa en el checkout del motor: motor, documentación y catálogo publicado. */
197
+ async function retiredStateFiles(root) {
198
+ const files = [
199
+ path.join(root, 'core', 'engine', 'engine.yaml'),
200
+ path.join(root, 'AGENTS.md'),
201
+ path.join(root, 'README.md'),
202
+ path.join(root, 'core', 'README.md'),
203
+ ];
204
+ for (const dir of ['core/rules', 'core/agents', 'core/skills/shared']) {
205
+ for (const rel of await listNames(path.join(root, ...dir.split('/')))) {
206
+ if (TEXT_FILE.test(rel)) {
207
+ files.push(path.join(root, ...dir.split('/'), ...rel.split('/')));
208
+ }
209
+ }
210
+ }
211
+ return files;
212
+ }
213
+ /**
214
+ * Gate del vocabulario de estados retirado (`ROOT_CAUSE`, `FIXED`, `READY`…): devuelve
215
+ * `ruta:línea vocabulario obsoleto "TOKEN"` relativo a `root`.
216
+ */
217
+ export async function scanRetiredStates(files, root) {
218
+ const findings = [];
219
+ for (const file of files ?? (await retiredStateFiles(root))) {
220
+ const text = await fs.readFile(file, 'utf8').catch(() => null);
221
+ if (text === null) {
222
+ continue;
223
+ }
224
+ const rel = path.relative(root, file).split(path.sep).join('/');
225
+ text.split(/\r?\n/).forEach((line, index) => {
226
+ for (const token of retiredInLine(line)) {
227
+ findings.push(`${rel}:${index + 1} vocabulario obsoleto "${token}"`);
228
+ }
229
+ });
230
+ }
231
+ return findings;
232
+ }
@@ -49,5 +49,11 @@ export interface ResumeSummary {
49
49
  done: number;
50
50
  };
51
51
  state: ProjectState | null;
52
+ /** Feature de Spec Kit activa y de dónde salió: `state` (versionado) o `local` (.specify/feature.json). */
53
+ feature: {
54
+ dir: string;
55
+ source: 'state' | 'local';
56
+ pending: number;
57
+ } | null;
52
58
  }
53
59
  export declare function resumeLedger(root: string): Promise<ResumeSummary>;