agentic-workflow-manager 3.13.5 → 3.13.6

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/dist/src/commands/add.js +5 -0
  2. package/dist/src/commands/doctor.js +1 -0
  3. package/dist/src/commands/hooks/claude.js +15 -2
  4. package/dist/src/commands/hooks/shared.js +6 -1
  5. package/dist/src/commands/init.js +1 -0
  6. package/dist/src/commands/pin.js +24 -4
  7. package/dist/src/commands/preflight/checks.js +3 -2
  8. package/dist/src/commands/registry/add.js +1 -1
  9. package/dist/src/commands/sensors/index.js +7 -0
  10. package/dist/src/commands/sensors/init.js +48 -7
  11. package/dist/src/commands/sensors/install.js +20 -11
  12. package/dist/src/commands/sensors/run.js +31 -29
  13. package/dist/src/commands/sync.js +29 -0
  14. package/dist/src/commands/watch/index.js +11 -1
  15. package/dist/src/core/artifact-name.js +80 -0
  16. package/dist/src/core/bundle-install.js +55 -12
  17. package/dist/src/core/bundles.js +72 -13
  18. package/dist/src/core/context/regenerate.js +21 -4
  19. package/dist/src/core/context/strategies/codex-agents.js +18 -2
  20. package/dist/src/core/diagnostics/checks.js +26 -2
  21. package/dist/src/core/diagnostics/context.js +91 -25
  22. package/dist/src/core/diagnostics/provider-checks.js +62 -14
  23. package/dist/src/core/discovery.js +2 -2
  24. package/dist/src/core/init/mutation-targets.js +11 -0
  25. package/dist/src/core/init/steps.js +70 -19
  26. package/dist/src/core/install-planner.js +62 -17
  27. package/dist/src/core/install-transaction.js +13 -1
  28. package/dist/src/core/journal/adapter.js +10 -1
  29. package/dist/src/core/paths.js +61 -8
  30. package/dist/src/core/profile.js +12 -0
  31. package/dist/src/core/provider-artifacts.js +38 -13
  32. package/dist/src/core/provider-version.js +12 -6
  33. package/dist/src/core/registries.js +40 -19
  34. package/dist/src/core/renderers/registry.js +37 -0
  35. package/dist/src/core/skill-integrity.js +71 -10
  36. package/dist/src/core/update-check.js +14 -2
  37. package/dist/src/core/versioning.js +17 -2
  38. package/dist/src/index.js +31 -23
  39. package/dist/src/providers/index.js +7 -2
  40. package/dist/src/utils/config.js +7 -8
  41. package/dist/tests/commands/doctor.test.js +6 -0
  42. package/dist/tests/commands/pin.test.js +29 -2
  43. package/dist/tests/commands/preflight/preflight.test.js +31 -17
  44. package/dist/tests/commands/registry/install-bundles.test.js +6 -6
  45. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +96 -0
  46. package/dist/tests/commands/sensors/install-settings-safety.test.js +87 -0
  47. package/dist/tests/commands/sensors/run-is-read-only.test.js +0 -0
  48. package/dist/tests/commands/sensors/run.test.js +24 -51
  49. package/dist/tests/commands/sensors/status-windows.test.js +39 -22
  50. package/dist/tests/commands/sensors/status.test.js +27 -12
  51. package/dist/tests/core/artifact-name-containment.test.js +86 -0
  52. package/dist/tests/core/context/agents-md-single-slot.test.js +69 -0
  53. package/dist/tests/core/context/regenerate.test.js +39 -8
  54. package/dist/tests/core/diagnostics/checks.test.js +14 -6
  55. package/dist/tests/core/diagnostics/provider-tier.test.js +3 -3
  56. package/dist/tests/core/diagnostics/rendered-artifact-visibility.test.js +69 -0
  57. package/dist/tests/core/init/all-baseline-bundles.test.js +64 -0
  58. package/dist/tests/core/init/context-injection-no-project.test.js +117 -0
  59. package/dist/tests/core/init/orchestrator.test.js +1 -1
  60. package/dist/tests/core/init/steps-registry-sync.test.js +1 -1
  61. package/dist/tests/core/init/steps.test.js +11 -5
  62. package/dist/tests/core/path-resolution-no-shell.test.js +136 -0
  63. package/dist/tests/core/paths.test.js +7 -34
  64. package/dist/tests/core/project-skill-links.test.js +159 -0
  65. package/dist/tests/core/provider-artifacts.test.js +66 -14
  66. package/dist/tests/core/provider-capability-guards.test.js +92 -0
  67. package/dist/tests/core/reconciliation.test.js +15 -15
  68. package/dist/tests/core/registry-manifest.test.js +1 -1
  69. package/dist/tests/core/semver-fails-closed.test.js +30 -0
  70. package/dist/tests/core/skill-integrity.test.js +10 -10
  71. package/dist/tests/core/skill-repair-safety.test.js +92 -0
  72. package/dist/tests/core/sync-profile-atomicity.test.js +122 -0
  73. package/dist/tests/integration/copilot-init-isolated.test.js +2 -2
  74. package/dist/tests/providers/index.test.js +1 -1
  75. package/dist/tests/structural/renderer-table-is-single-source.test.js +95 -0
  76. package/dist/tests/structural/symlink-type-is-explicit.test.js +73 -0
  77. package/dist/tests/utils/registry-view-overrides.test.js +2 -2
  78. package/package.json +4 -1
@@ -42,24 +42,76 @@ describe('scanLegacyArtifacts', () => {
42
42
  process.env.AWM_HOME = originalAwmHome;
43
43
  fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
44
44
  });
45
- it('never lists Codex TOML agents for legacy removal', () => {
45
+ /** Registra artefactos como propiedad de AWM, que es lo que ahora habilita
46
+ * que `awm remove` los ofrezca. */
47
+ function own(records) {
48
+ const file = path_1.default.join(tmpHome, '.awm', 'state', 'artifacts.json');
49
+ fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
50
+ fs_1.default.writeFileSync(file, JSON.stringify(records.map((r) => ({
51
+ ...r, scope: 'global', sourcePath: '/tmp/src', owners: ['codex'],
52
+ }))));
53
+ }
54
+ it('lista los artefactos renderizados que AWM instalo, incluidos los .toml de codex', () => {
55
+ // Este test asertaba lo contrario: que los agents TOML de codex NUNCA
56
+ // se listaran. Eso codificaba el bug — `scanLegacyArtifacts` se tragaba
57
+ // `UnsupportedRendererError`, asi que `awm remove` era un no-op
58
+ // permanente para los skills de cursor/copilot y los agents de codex
59
+ // ("no hay artefactos instalados", con el directorio lleno).
60
+ // Un artefacto que AWM instalo tiene que poder desinstalarse.
46
61
  const skillPath = path_1.default.join(tmpHome, '.agents/skills/linked-skill');
47
- const agentPath = path_1.default.join(tmpHome, '.codex/agents/protected.toml');
62
+ const agentPath = path_1.default.join(tmpHome, '.codex/agents/rendered.toml');
48
63
  fs_1.default.mkdirSync(skillPath, { recursive: true });
49
64
  fs_1.default.mkdirSync(path_1.default.dirname(agentPath), { recursive: true });
50
- fs_1.default.writeFileSync(agentPath, 'name = "protected"\n');
65
+ fs_1.default.writeFileSync(agentPath, 'name = "rendered"\n');
66
+ own([
67
+ { name: 'linked-skill', type: 'skill', targetPath: skillPath, renderer: 'link' },
68
+ { name: 'rendered.toml', type: 'agent', targetPath: agentPath, renderer: 'codex-agent-toml' },
69
+ ]);
51
70
  const listed = (0, provider_artifacts_1.scanLegacyArtifacts)(['codex'], 'global');
52
- const legacyRemove = jest.fn((target) => fs_1.default.rmSync(target, {
53
- recursive: true,
54
- force: true,
55
- }));
56
- for (const artifact of listed) {
57
- for (const fullPath of artifact.fullPaths)
58
- legacyRemove(fullPath);
59
- }
60
- expect(listed.map((artifact) => artifact.name)).toEqual(['linked-skill']);
61
- expect(legacyRemove).not.toHaveBeenCalledWith(agentPath);
62
- expect(fs_1.default.existsSync(agentPath)).toBe(true);
71
+ expect(listed.map((a) => a.name).sort()).toEqual(['linked-skill', 'rendered.toml']);
72
+ });
73
+ it('NO lista archivos que AWM no instalo — el directorio no es suyo para ofrecerlo', () => {
74
+ // Antes listaba el directorio entero, asi que un skill escrito a mano
75
+ // por el usuario aparecia en el menu de borrado.
76
+ const mine = path_1.default.join(tmpHome, '.agents/skills/awm-skill');
77
+ const theirs = path_1.default.join(tmpHome, '.agents/skills/mi-skill-personal');
78
+ fs_1.default.mkdirSync(mine, { recursive: true });
79
+ fs_1.default.mkdirSync(theirs, { recursive: true });
80
+ own([{ name: 'awm-skill', type: 'skill', targetPath: mine, renderer: 'link' }]);
81
+ const listed = (0, provider_artifacts_1.scanLegacyArtifacts)(['codex'], 'global');
82
+ expect(listed.map((a) => a.name)).toEqual(['awm-skill']);
83
+ expect(listed.flatMap((a) => a.fullPaths)).not.toContain(theirs);
84
+ });
85
+ it('no colapsa dos tipos distintos que comparten nombre (borraria el equivocado)', () => {
86
+ // Antes la clave era solo el nombre, a traves de todos los tipos: un
87
+ // workflow `deploy.md` y un agent `deploy.md` colapsaban en UNA entrada,
88
+ // asi que elegir "deploy.md" borraba ambos.
89
+ const wf = path_1.default.join(tmpHome, '.gemini/antigravity/global_workflows/deploy.md');
90
+ const ag = path_1.default.join(tmpHome, '.claude/agents/deploy.md');
91
+ fs_1.default.mkdirSync(path_1.default.dirname(wf), { recursive: true });
92
+ fs_1.default.mkdirSync(path_1.default.dirname(ag), { recursive: true });
93
+ fs_1.default.writeFileSync(wf, 'wf');
94
+ fs_1.default.writeFileSync(ag, 'ag');
95
+ own([
96
+ { name: 'deploy.md', type: 'workflow', targetPath: wf, renderer: 'link' },
97
+ { name: 'deploy.md', type: 'agent', targetPath: ag, renderer: 'link' },
98
+ ]);
99
+ const listed = (0, provider_artifacts_1.scanLegacyArtifacts)(['antigravity', 'claude-code'], 'global');
100
+ expect(listed).toHaveLength(2);
101
+ for (const a of listed)
102
+ expect(a.fullPaths).toHaveLength(1);
103
+ });
104
+ it('scope local se resuelve contra el projectRoot dado, no contra cwd', () => {
105
+ const projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-proj-'));
106
+ const local = path_1.default.join(projectRoot, '.claude/skills/local-skill');
107
+ fs_1.default.mkdirSync(local, { recursive: true });
108
+ own([{ name: 'local-skill', type: 'skill', targetPath: local, renderer: 'link' }]);
109
+ const listed = (0, provider_artifacts_1.scanLegacyArtifacts)(['claude-code'], 'local', projectRoot);
110
+ expect(listed.map((a) => a.name)).toEqual(['local-skill']);
111
+ // Y la ruta entregada es ABSOLUTA: antes era relativa, y se la pasaba
112
+ // tal cual a fs.rmSync.
113
+ expect(path_1.default.isAbsolute(listed[0].fullPaths[0])).toBe(true);
114
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
63
115
  });
64
116
  it('Gap C — an agent with a null global skill dir (copilot) at global scope skips cleanly instead of crashing', () => {
65
117
  // Copilot's skill.global is null (no user-level skill discovery mechanism —
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ // Regresion: un provider que estructuralmente NO puede recibir un artefacto en
7
+ // cierto scope (hoy: copilot en global — no tiene descubrimiento de skills a
8
+ // nivel usuario) hacia TIRAR el plan entero, rompiendo el comando para todos
9
+ // los demas agentes de la maquina.
10
+ //
11
+ // Sintoma confirmado contra el binario real:
12
+ // awm init -a claude-code -y && awm init -a copilot -y && awm update
13
+ // → Artifact reconciliation failed: skill global scope is not supported by Copilot
14
+ // exit=1
15
+ // El comando moria ANTES del re-sync de hooks, asi que claude-code dejaba de
16
+ // recibir actualizaciones — y no existe comando para deshabilitar un agente,
17
+ // mientras `awm doctor` seguia diciendo "healthy".
18
+ //
19
+ // La causa era una asimetria de guarda clasica: el `.filter()` interno de
20
+ // `assertCompleteSharedGroup` ya toleraba el caso, pero la linea de arriba
21
+ // —que corre primero— no.
22
+ const path_1 = __importDefault(require("path"));
23
+ const install_planner_1 = require("../../src/core/install-planner");
24
+ const skill = {
25
+ name: 'using-awm', type: 'skill', installName: 'using-awm', sourcePath: '/tmp/reg/skills/using-awm',
26
+ };
27
+ describe('planInstall: un provider incapaz no puede romper el plan de los demas', () => {
28
+ it('scope global con copilot habilitado: planifica para los capaces y saltea copilot', () => {
29
+ const plan = (0, install_planner_1.planInstall)({
30
+ artifacts: [skill],
31
+ selectedAgents: ['claude-code', 'copilot'],
32
+ enabledAgents: ['claude-code', 'copilot'],
33
+ scope: 'global',
34
+ projectRoot: '/tmp/proj',
35
+ method: 'symlink',
36
+ });
37
+ const owners = plan.operations.flatMap((op) => op.owners);
38
+ expect(owners).toContain('claude-code');
39
+ expect(owners).not.toContain('copilot');
40
+ });
41
+ it('no tira aunque copilot este habilitado pero no seleccionado (el caso de awm update)', () => {
42
+ expect(() => (0, install_planner_1.planInstall)({
43
+ artifacts: [skill],
44
+ selectedAgents: ['claude-code'],
45
+ enabledAgents: ['claude-code', 'copilot'],
46
+ scope: 'global',
47
+ projectRoot: '/tmp/proj',
48
+ method: 'symlink',
49
+ })).not.toThrow();
50
+ });
51
+ it('peticion EXPLICITA de copilot en global: sigue tirando CON la explicacion', () => {
52
+ // Aca no hay a quien saltear — el usuario pidio justo esa combinacion, y
53
+ // lo que necesita es leer por que no se puede, no un exito silencioso.
54
+ expect(() => (0, install_planner_1.planInstall)({
55
+ artifacts: [skill],
56
+ selectedAgents: ['copilot'],
57
+ enabledAgents: ['copilot'],
58
+ scope: 'global',
59
+ projectRoot: '/tmp/proj',
60
+ method: 'symlink',
61
+ })).toThrow(/per-project|not supported/i);
62
+ });
63
+ it('copilot en scope local SI recibe el artefacto (su unico scope valido)', () => {
64
+ const plan = (0, install_planner_1.planInstall)({
65
+ artifacts: [skill],
66
+ selectedAgents: ['copilot'],
67
+ enabledAgents: ['copilot'],
68
+ scope: 'local',
69
+ projectRoot: '/tmp/proj',
70
+ method: 'symlink',
71
+ });
72
+ expect(plan.operations).toHaveLength(1);
73
+ // `path.join`, no un literal con '/': `physicalTarget` construye la ruta con el
74
+ // separador de la plataforma, asi que en Windows el destino real es
75
+ // `.github\instructions` y la asercion POSIX-hardcodeada fallaba sobre un
76
+ // destino perfectamente correcto.
77
+ expect(plan.operations[0].targetPath).toContain(path_1.default.join('.github', 'instructions'));
78
+ expect(plan.operations[0].targetPath).toContain('using-awm.instructions.md');
79
+ });
80
+ it('el grupo compartido (opencode + codex) se sigue exigiendo completo', () => {
81
+ // La guarda no debe aflojar R14: ambos resuelven a ~/.agents/skills, asi
82
+ // que seleccionar solo uno seguiria siendo un cambio divergente.
83
+ expect(() => (0, install_planner_1.planInstall)({
84
+ artifacts: [skill],
85
+ selectedAgents: ['codex'],
86
+ enabledAgents: ['codex', 'opencode'],
87
+ scope: 'global',
88
+ projectRoot: '/tmp/proj',
89
+ method: 'symlink',
90
+ })).toThrow(/shared skill target/i);
91
+ });
92
+ });
@@ -138,10 +138,18 @@ describe('planReconciliation (real, unmocked)', () => {
138
138
  // points nowhere — resolveBundleClosure just drops the missing dep.
139
139
  expect(names).toEqual(['ambient-skill', 'brainstorming', 'dangling-skill']);
140
140
  });
141
- it('skips a bundle whose closure genuinely fails to resolve, without aborting reconciliation for the rest', () => {
142
- // Malformed dependsOn (not an array) makes resolveBundleClosure throw
143
- // when it tries to iterate it a real "unresolvable closure" error,
144
- // unlike a plain dangling name reference (see test above).
141
+ it('un dependsOn malformado ya no rompe nada: el campo invalido se ignora y el bundle instala', () => {
142
+ // Este test asertaba que un `dependsOn` no-array hacia TIRAR a
143
+ // `resolveBundleClosure` y que reconciliation lo salteaba. Desde que
144
+ // `discoverBundles` valida forma sobre el contenido del registry (que es
145
+ // input no confiable), ese campo se normaliza a `[]` en vez de
146
+ // propagarse hasta un `for...of` sobre un numero. O sea: la entrada ya
147
+ // no falla, y el bundle instala normalmente ignorando el campo roto.
148
+ //
149
+ // Es una mejora, no una regresion — antes UN archivo malformado en
150
+ // cualquier registry tumbaba `awm list`/`awm add` con un stack trace
151
+ // crudo. El `catch` de reconciliation sigue en su lugar como defensa en
152
+ // profundidad, pero esta entrada ya no lo ejercita.
145
153
  writeBundle(contentRoot, 'broken', 'baseline', ['broken-skill'], 5);
146
154
  addToCatalog(contentRoot, 'broken', 'baseline');
147
155
  const { planReconciliation } = require('../../src/core/reconciliation');
@@ -149,17 +157,9 @@ describe('planReconciliation (real, unmocked)', () => {
149
157
  expect(() => {
150
158
  plan = planReconciliation({ targets: ['claude-code'], roots: [contentRoot] });
151
159
  }).not.toThrow();
152
- // The healthy bundles (baseline 'dev' + ambient 'ambient-x') still
153
- // reconcile correctly.
154
160
  const names = plan.operations.map((op) => op.name).sort();
155
- expect(names).toEqual(['ambient-skill', 'brainstorming']);
156
- // The broken bundle is cleanly absent no partial/broken entry for
157
- // 'broken-skill' or any operation touching it.
158
- expect(names).not.toContain('broken-skill');
159
- expect(plan.operations.some((op) => op.sourcePath?.includes('broken'))).toBe(false);
160
- // Applying the plan end-to-end still works (no dangling reference to
161
- // the broken bundle leaked into the plan).
162
- const { applyInstallPlan } = require('../../src/core/install-transaction');
163
- expect(() => applyInstallPlan(plan)).not.toThrow();
161
+ // Los bundles sanos siguen reconciliando, y el que tenia el campo roto
162
+ // tambiensin su dependencia invalida.
163
+ expect(names).toEqual(['ambient-skill', 'brainstorming', 'broken-skill']);
164
164
  });
165
165
  });
@@ -69,7 +69,7 @@ describe('registry manifest (awm-registry.json)', () => {
69
69
  });
70
70
  it('registryNameForPath maps configured registries and returns null for unknown paths', () => {
71
71
  const m = load();
72
- const regPath = path_1.default.join(m.REGISTRIES_DIR, 'team-acme', 'skills', 'x');
72
+ const regPath = path_1.default.join(m.registriesDir(), 'team-acme', 'skills', 'x');
73
73
  expect(m.registryNameForPath(regPath)).toBe('team-acme');
74
74
  expect(m.registryNameForPath('/somewhere/else')).toBeNull();
75
75
  });
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // Regresion: `compareSemver` devolvia NaN ante una entrada malformada, y como
4
+ // `NaN > 0` y `NaN < 0` son AMBOS false, el gate de version fallaba ABIERTO:
5
+ // `verifyMinCliVersions` dejaba pasar en silencio un CLI por debajo del minimo.
6
+ // Para un gate, la direccion correcta de fallo es la contraria.
7
+ const versioning_1 = require("../../src/core/versioning");
8
+ describe('compareSemver', () => {
9
+ it.each([
10
+ ['1.2.3', '1.2.3', 0],
11
+ ['1.2.4', '1.2.3', 1],
12
+ ['1.2.3', '1.3.0', -1],
13
+ ['2.0.0', '1.99.99', 1],
14
+ ['v1.2.3', '1.2.3', 0],
15
+ ])('compara %s con %s', (a, b, sign) => {
16
+ expect(Math.sign((0, versioning_1.compareSemver)(a, b))).toBe(sign);
17
+ });
18
+ it.each([
19
+ ['incompleta', '1.2'],
20
+ ['no numerica', 'x.y.z'],
21
+ ['vacia', ''],
22
+ ['basura', 'latest'],
23
+ ])('lanza ante una version %s en vez de devolver NaN', (_n, bad) => {
24
+ expect(() => (0, versioning_1.compareSemver)(bad, '1.0.0')).toThrow(/not a semver/i);
25
+ expect(() => (0, versioning_1.compareSemver)('1.0.0', bad)).toThrow(/not a semver/i);
26
+ });
27
+ it('nunca devuelve NaN — el valor que hacia fallar el gate abierto', () => {
28
+ expect(Number.isNaN((0, versioning_1.compareSemver)('1.0.0', '2.0.0'))).toBe(false);
29
+ });
30
+ });
@@ -16,7 +16,7 @@ describe('skill-integrity multi-root', () => {
16
16
  afterEach(() => {
17
17
  fs_1.default.rmSync(tmp, { recursive: true, force: true });
18
18
  });
19
- it('repairGlobalSkills re-links a dangling symlink to the FIRST root that has the skill', () => {
19
+ it('repairSkillLinks re-links a dangling symlink to the FIRST root that has the skill', () => {
20
20
  const skillsDir = path_1.default.join(tmp, 'installed');
21
21
  const rootA = path_1.default.join(tmp, 'base');
22
22
  const rootB = path_1.default.join(tmp, 'personal');
@@ -25,16 +25,16 @@ describe('skill-integrity multi-root', () => {
25
25
  fs_1.default.writeFileSync(path_1.default.join(rootB, 'skills', 'mine', 'SKILL.md'), '---\nname: mine\n---\n');
26
26
  // dangling symlink: points to a deleted target
27
27
  fs_1.default.symlinkSync(path_1.default.join(tmp, 'gone', 'mine'), path_1.default.join(skillsDir, 'mine'), 'dir');
28
- const { repairGlobalSkills: repair } = require('../../src/core/skill-integrity');
28
+ const { repairSkillLinks: repair } = require('../../src/core/skill-integrity');
29
29
  const r = repair(skillsDir, [rootA, rootB]);
30
30
  expect(r.relinked).toEqual(['mine']);
31
31
  expect(fs_1.default.realpathSync(path_1.default.join(skillsDir, 'mine'))).toBe(fs_1.default.realpathSync(path_1.default.join(rootB, 'skills', 'mine')));
32
32
  });
33
- it('classifyGlobalSkills marks dead when NO root has the skill', () => {
33
+ it('classifySkillLinks marks dead when NO root has the skill', () => {
34
34
  const skillsDir = path_1.default.join(tmp, 'installed');
35
35
  fs_1.default.mkdirSync(skillsDir, { recursive: true });
36
36
  fs_1.default.symlinkSync(path_1.default.join(tmp, 'gone', 'nope'), path_1.default.join(skillsDir, 'nope'), 'dir');
37
- const { classifyGlobalSkills: classify } = require('../../src/core/skill-integrity');
37
+ const { classifySkillLinks: classify } = require('../../src/core/skill-integrity');
38
38
  const c = classify(skillsDir, [path_1.default.join(tmp, 'base')]);
39
39
  expect(c.dead).toEqual(['nope']);
40
40
  });
@@ -53,7 +53,7 @@ function makeRegistrySkill(registryContentDir, name) {
53
53
  fs_1.default.writeFileSync(path_1.default.join(dir, 'SKILL.md'), `# ${name}\n`);
54
54
  return dir;
55
55
  }
56
- describe('classifyGlobalSkills', () => {
56
+ describe('classifySkillLinks', () => {
57
57
  it('classifies valid / repairable / dead', () => {
58
58
  const { skillsDir, registryContentDir } = setup();
59
59
  // valid: symlink a una skill que existe en el registry
@@ -64,13 +64,13 @@ describe('classifyGlobalSkills', () => {
64
64
  fs_1.default.symlinkSync(path_1.default.join('/nonexistent/old-root/beta'), path_1.default.join(skillsDir, 'beta'), 'dir');
65
65
  // dead: symlink colgante y la skill NO existe en el registry
66
66
  fs_1.default.symlinkSync(path_1.default.join('/nonexistent/old-root/gamma'), path_1.default.join(skillsDir, 'gamma'), 'dir');
67
- const result = (0, skill_integrity_1.classifyGlobalSkills)(skillsDir, [registryContentDir]);
67
+ const result = (0, skill_integrity_1.classifySkillLinks)(skillsDir, [registryContentDir]);
68
68
  expect(result.valid).toEqual(['alpha']);
69
69
  expect(result.repairable).toEqual(['beta']);
70
70
  expect(result.dead).toEqual(['gamma']);
71
71
  });
72
72
  it('returns empty arrays when the skills dir does not exist', () => {
73
- const result = (0, skill_integrity_1.classifyGlobalSkills)('/nonexistent/dir', ['/also/nonexistent']);
73
+ const result = (0, skill_integrity_1.classifySkillLinks)('/nonexistent/dir', ['/also/nonexistent']);
74
74
  expect(result).toEqual({ valid: [], repairable: [], dead: [] });
75
75
  });
76
76
  });
@@ -117,7 +117,7 @@ describe('reconcileAllSkillLinks (#4 — awm update, all providers)', () => {
117
117
  }
118
118
  });
119
119
  });
120
- describe('repairGlobalSkills', () => {
120
+ describe('repairSkillLinks', () => {
121
121
  it('re-links repairable to registry content dir and prunes dead; valid untouched; idempotent', () => {
122
122
  const { skillsDir, registryContentDir } = setup();
123
123
  const okTarget = makeRegistrySkill(registryContentDir, 'alpha');
@@ -125,7 +125,7 @@ describe('repairGlobalSkills', () => {
125
125
  makeRegistrySkill(registryContentDir, 'beta');
126
126
  fs_1.default.symlinkSync(path_1.default.join('/nonexistent/old-root/beta'), path_1.default.join(skillsDir, 'beta'), 'dir');
127
127
  fs_1.default.symlinkSync(path_1.default.join('/nonexistent/old-root/gamma'), path_1.default.join(skillsDir, 'gamma'), 'dir');
128
- const r1 = (0, skill_integrity_1.repairGlobalSkills)(skillsDir, [registryContentDir]);
128
+ const r1 = (0, skill_integrity_1.repairSkillLinks)(skillsDir, [registryContentDir]);
129
129
  expect(r1.relinked).toEqual(['beta']);
130
130
  expect(r1.pruned).toEqual(['gamma']);
131
131
  // beta ahora apunta al registry content dir y resuelve
@@ -137,7 +137,7 @@ describe('repairGlobalSkills', () => {
137
137
  // alpha intacto
138
138
  expect(fs_1.default.existsSync(path_1.default.join(skillsDir, 'alpha'))).toBe(true);
139
139
  // idempotente: segunda corrida no cambia nada
140
- const r2 = (0, skill_integrity_1.repairGlobalSkills)(skillsDir, [registryContentDir]);
140
+ const r2 = (0, skill_integrity_1.repairSkillLinks)(skillsDir, [registryContentDir]);
141
141
  expect(r2.relinked).toEqual([]);
142
142
  expect(r2.pruned).toEqual([]);
143
143
  });
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ // Regresion: `repairSkillLinks` hacia `rmSync` del link colgante y RECIEN
7
+ // DESPUES intentaba recrearlo con `symlinkSync(..., 'dir')`. En Windows un
8
+ // symlink de directorio exige SeCreateSymbolicLinkPrivilege, denegado por
9
+ // defecto en cuentas sin privilegios — asi que el link se borraba y la
10
+ // recreacion tiraba EPERM: el usuario quedaba PEOR que antes, y con la entrada
11
+ // ya ausente el siguiente `awm init` ni siquiera podia verla como reparable.
12
+ //
13
+ // `executor.ts` documenta en extenso que en win32 hay que usar `'junction'`
14
+ // (que cualquier cuenta puede crear, y que libuv reporta igual que un symlink);
15
+ // este sitio nunca recibio ese fix.
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const os_1 = __importDefault(require("os"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const skill_integrity_1 = require("../../src/core/skill-integrity");
20
+ describe('repairSkillLinks: un fallo nunca deja al usuario peor que antes', () => {
21
+ let skillsDir;
22
+ let registry;
23
+ beforeEach(() => {
24
+ const base = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-repair-'));
25
+ skillsDir = path_1.default.join(base, 'skills-installed');
26
+ registry = path_1.default.join(base, 'registry');
27
+ fs_1.default.mkdirSync(skillsDir, { recursive: true });
28
+ fs_1.default.mkdirSync(path_1.default.join(registry, 'skills', 'alpha'), { recursive: true });
29
+ fs_1.default.writeFileSync(path_1.default.join(registry, 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\ndescription: d\n---\nb');
30
+ });
31
+ /** Un symlink colgante: apunta a algo que no existe, pero el registry SI
32
+ * tiene la skill — o sea, reparable. */
33
+ function danglingLink(name) {
34
+ fs_1.default.symlinkSync(path_1.default.join(skillsDir, '__no_existe__'), path_1.default.join(skillsDir, name), 'dir');
35
+ }
36
+ it('repara un link colgante apuntandolo al registry', () => {
37
+ danglingLink('alpha');
38
+ const result = (0, skill_integrity_1.repairSkillLinks)(skillsDir, [registry]);
39
+ expect(result.relinked).toContain('alpha');
40
+ expect(fs_1.default.existsSync(path_1.default.join(skillsDir, 'alpha'))).toBe(true);
41
+ expect(fs_1.default.realpathSync(path_1.default.join(skillsDir, 'alpha')))
42
+ .toBe(fs_1.default.realpathSync(path_1.default.join(registry, 'skills', 'alpha')));
43
+ });
44
+ it('si la creacion del link falla, el estado original NO se destruye', () => {
45
+ danglingLink('alpha');
46
+ const before = fs_1.default.readlinkSync(path_1.default.join(skillsDir, 'alpha'));
47
+ // Simula la denegacion de privilegio de Windows.
48
+ const spy = jest.spyOn(fs_1.default, 'symlinkSync').mockImplementation(() => {
49
+ const err = new Error('operation not permitted');
50
+ err.code = 'EPERM';
51
+ throw err;
52
+ });
53
+ try {
54
+ const result = (0, skill_integrity_1.repairSkillLinks)(skillsDir, [registry]);
55
+ expect(result.failed).toContain('alpha');
56
+ expect(result.relinked).not.toContain('alpha');
57
+ // Lo que importa: la entrada sigue existiendo, con su target
58
+ // original. Antes quedaba borrada — invisible incluso para el
59
+ // proximo intento de reparacion.
60
+ expect(fs_1.default.lstatSync(path_1.default.join(skillsDir, 'alpha')).isSymbolicLink()).toBe(true);
61
+ expect(fs_1.default.readlinkSync(path_1.default.join(skillsDir, 'alpha'))).toBe(before);
62
+ }
63
+ finally {
64
+ spy.mockRestore();
65
+ }
66
+ });
67
+ it('no deja archivos de staging tirados tras una reparacion exitosa', () => {
68
+ danglingLink('alpha');
69
+ (0, skill_integrity_1.repairSkillLinks)(skillsDir, [registry]);
70
+ expect(fs_1.default.readdirSync(skillsDir).filter((e) => e.includes('.relink'))).toEqual([]);
71
+ });
72
+ it('no deja archivos de staging tirados tras un fallo', () => {
73
+ danglingLink('alpha');
74
+ const spy = jest.spyOn(fs_1.default, 'renameSync').mockImplementation(() => { throw new Error('boom'); });
75
+ try {
76
+ (0, skill_integrity_1.repairSkillLinks)(skillsDir, [registry]);
77
+ }
78
+ finally {
79
+ spy.mockRestore();
80
+ }
81
+ // El staging puede sobrevivir a un crash duro, pero se limpia en el
82
+ // siguiente intento — se verifica que una segunda pasada converge.
83
+ (0, skill_integrity_1.repairSkillLinks)(skillsDir, [registry]);
84
+ expect(fs_1.default.readdirSync(skillsDir).filter((e) => e.includes('.relink'))).toEqual([]);
85
+ });
86
+ it('poda un link muerto (sin contraparte en el registry)', () => {
87
+ danglingLink('fantasma');
88
+ const result = (0, skill_integrity_1.repairSkillLinks)(skillsDir, [registry]);
89
+ expect(result.pruned).toContain('fantasma');
90
+ expect(fs_1.default.existsSync(path_1.default.join(skillsDir, 'fantasma'))).toBe(false);
91
+ });
92
+ });
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ // `awm sync` over a profile with N extensions ran N independent transactions —
7
+ // one `planInstall` + `applyInstallPlan` per extension, in a loop. Each is atomic on
8
+ // its own, none is atomic with the others, so a failure on extension 3 left 1 and 2
9
+ // installed and the tree in a state that matched neither before nor after:
10
+ //
11
+ // extensions: [a, b, c]
12
+ // a → tx-1 committed
13
+ // b → tx-2 committed
14
+ // c → throws ← a and b stay installed, sync exits non-zero
15
+ //
16
+ // And the transaction IDs — the only handle `awm backup restore` accepts — were
17
+ // collected into `transactionIds` and then never printed by any caller, so even the
18
+ // committed halves could not be named to undo them by hand.
19
+ //
20
+ // One plan for the whole sync. `planInstall` already dedups by physical target across
21
+ // artifacts, so combining the extensions loses nothing; and `applyInstallPlan` already
22
+ // rolls its own plan back on any failure, so combining is what makes the sync atomic.
23
+ const fs_1 = __importDefault(require("fs"));
24
+ const os_1 = __importDefault(require("os"));
25
+ const path_1 = __importDefault(require("path"));
26
+ const bundle_install_1 = require("../../src/core/bundle-install");
27
+ function bundle(name, skills, contentRoot) {
28
+ return {
29
+ name, description: '', version: '1.0.0', scope: 'project', visibility: 'public',
30
+ dependsOn: [], skills: skills.map(n => ({ name: n })), workflows: [], agents: [],
31
+ source: `bundles/${name}`, contentRoot,
32
+ };
33
+ }
34
+ describe('awm sync is one transaction, not one per extension', () => {
35
+ let projectRoot;
36
+ let contentDir;
37
+ let home;
38
+ let saved;
39
+ let bundles;
40
+ beforeEach(() => {
41
+ home = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-atomic-home-'));
42
+ saved = { HOME: process.env.HOME, AWM_HOME: process.env.AWM_HOME };
43
+ process.env.HOME = home;
44
+ process.env.AWM_HOME = path_1.default.join(home, '.awm');
45
+ fs_1.default.mkdirSync(path_1.default.join(home, '.awm'), { recursive: true });
46
+ fs_1.default.writeFileSync(path_1.default.join(home, '.awm', 'preferences.json'), JSON.stringify({
47
+ enabledAgents: ['claude-code'], defaultAgent: 'claude-code',
48
+ installMethod: 'symlink', defaultScope: 'global',
49
+ }));
50
+ contentDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-atomic-content-'));
51
+ for (const s of ['skill-a', 'skill-b', 'skill-c']) {
52
+ fs_1.default.mkdirSync(path_1.default.join(contentDir, 'skills', s), { recursive: true });
53
+ fs_1.default.writeFileSync(path_1.default.join(contentDir, 'skills', s, 'SKILL.md'), `---\nname: ${s}\ndescription: d\n---\n`);
54
+ }
55
+ bundles = [
56
+ bundle('ext-a', ['skill-a'], contentDir),
57
+ bundle('ext-b', ['skill-b'], contentDir),
58
+ bundle('ext-c', ['skill-c'], contentDir),
59
+ ];
60
+ projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-atomic-proj-'));
61
+ fs_1.default.mkdirSync(path_1.default.join(projectRoot, '.awm'), { recursive: true });
62
+ fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'profile.json'), JSON.stringify({ extensions: ['ext-a', 'ext-b', 'ext-c'] }));
63
+ });
64
+ afterEach(() => {
65
+ if (saved.HOME === undefined)
66
+ delete process.env.HOME;
67
+ else
68
+ process.env.HOME = saved.HOME;
69
+ if (saved.AWM_HOME === undefined)
70
+ delete process.env.AWM_HOME;
71
+ else
72
+ process.env.AWM_HOME = saved.AWM_HOME;
73
+ for (const d of [home, contentDir, projectRoot])
74
+ fs_1.default.rmSync(d, { recursive: true, force: true });
75
+ });
76
+ const run = (applyPlan) => (0, bundle_install_1.syncProfile)({ projectRoot, bundles, agents: ['claude-code'], method: 'symlink', contentDir, applyPlan });
77
+ it('applies a single plan covering every extension', () => {
78
+ const plans = [];
79
+ const result = run((plan) => {
80
+ plans.push(plan);
81
+ return { installed: plan.operations.map(o => o.targetPath), skipped: [], transactionId: 'tx-1', modifiedFiles: [] };
82
+ });
83
+ expect(plans).toHaveLength(1);
84
+ const names = plans[0].operations.map(o => path_1.default.basename(o.targetPath)).sort();
85
+ expect(names).toEqual(['skill-a', 'skill-b', 'skill-c']);
86
+ expect(result.transactionIds).toEqual(['tx-1']);
87
+ });
88
+ it('installs nothing at all when the apply fails — no half-synced tree', () => {
89
+ // The failure is on the LAST extension on purpose: with one transaction per
90
+ // extension, ext-a and ext-b were already committed by the time ext-c blew up.
91
+ const committed = [];
92
+ expect(() => run((plan) => {
93
+ const names = plan.operations.map(o => path_1.default.basename(o.targetPath));
94
+ if (names.includes('skill-c'))
95
+ throw new Error('verification failed');
96
+ committed.push(...names);
97
+ return { installed: names, skipped: [], transactionId: `tx-${committed.length}`, modifiedFiles: [] };
98
+ })).toThrow(/verification failed/);
99
+ expect(committed).toEqual([]);
100
+ });
101
+ it('skips unknown extensions without splitting the transaction', () => {
102
+ fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'profile.json'), JSON.stringify({ extensions: ['ext-a', 'ghost', 'ext-c'] }));
103
+ const plans = [];
104
+ const result = run((plan) => {
105
+ plans.push(plan);
106
+ return { installed: [], skipped: [], transactionId: 'tx-1', modifiedFiles: [] };
107
+ });
108
+ expect(plans).toHaveLength(1);
109
+ expect(plans[0].operations.map(o => path_1.default.basename(o.targetPath)).sort()).toEqual(['skill-a', 'skill-c']);
110
+ expect(result.skipped.some(s => s.includes('ghost'))).toBe(true);
111
+ });
112
+ it('does not apply anything when every extension is unknown', () => {
113
+ fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'profile.json'), JSON.stringify({ extensions: ['ghost'] }));
114
+ let applyCalls = 0;
115
+ const result = run(() => {
116
+ applyCalls++;
117
+ return { installed: [], skipped: [], transactionId: 'tx-1', modifiedFiles: [] };
118
+ });
119
+ expect(applyCalls).toBe(0);
120
+ expect(result.transactionIds).toEqual([]);
121
+ });
122
+ });
@@ -75,9 +75,9 @@ describe('copilot provider — isolated home E2E (devCore global-scope guard reg
75
75
  fs_1.default.writeFileSync(path_1.default.join(root, 'hooks/session-start'), '#!/bin/sh\necho "{}"\n', { mode: 0o755 });
76
76
  fs_1.default.writeFileSync(path_1.default.join(root, 'hooks/run-hook.cmd'), '#!/bin/sh\nexec sh "$1"\n', { mode: 0o755 });
77
77
  fs_1.default.mkdirSync(path_1.default.join(root, 'skills/using-awm'), { recursive: true });
78
- fs_1.default.writeFileSync(path_1.default.join(root, 'skills/using-awm/SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
78
+ fs_1.default.writeFileSync(path_1.default.join(root, 'skills/using-awm/SKILL.md'), '---\nname: using-awm\ndescription: Entry point for AWM skills.\n---\nMUST invoke skills.');
79
79
  fs_1.default.mkdirSync(path_1.default.join(root, 'skills/development-process'), { recursive: true });
80
- fs_1.default.writeFileSync(path_1.default.join(root, 'skills/development-process/SKILL.md'), '---\nname: development-process\n---\nOrchestrates the dev lifecycle.');
80
+ fs_1.default.writeFileSync(path_1.default.join(root, 'skills/development-process/SKILL.md'), '---\nname: development-process\ndescription: Orchestrates the development lifecycle.\n---\nOrchestrates the dev lifecycle.');
81
81
  fs_1.default.writeFileSync(path_1.default.join(root, 'catalog.json'), JSON.stringify({
82
82
  version: 1,
83
83
  bundles: [{ name: 'dev-core', source: 'bundles/dev-core', version: '1.0.0', scope: 'baseline', visibility: 'public' }],
@@ -56,7 +56,7 @@ describe('Providers Routing', () => {
56
56
  expect((0, providers_1.providerFor)('codex')).toEqual({
57
57
  label: 'Codex',
58
58
  minimumVersion: '0.145.0',
59
- versionCommand: { command: 'codex', args: ['--version'] },
59
+ versionCommand: { command: 'codex', args: ['--version'], versionPattern: /^codex-cli (\d+\.\d+\.\d+)$/ },
60
60
  skill: {
61
61
  global: path_1.default.join(tmpHome, '.agents/skills'),
62
62
  local: '.agents/skills',