agentic-workflow-manager 3.13.5 → 3.13.7

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 (84) hide show
  1. package/dist/scripts/support-matrix.js +138 -0
  2. package/dist/src/commands/add.js +5 -0
  3. package/dist/src/commands/doctor.js +1 -0
  4. package/dist/src/commands/hooks/claude.js +15 -2
  5. package/dist/src/commands/hooks/shared.js +6 -1
  6. package/dist/src/commands/init.js +1 -0
  7. package/dist/src/commands/pin.js +24 -4
  8. package/dist/src/commands/preflight/checks.js +3 -2
  9. package/dist/src/commands/registry/add.js +1 -1
  10. package/dist/src/commands/sensors/index.js +7 -0
  11. package/dist/src/commands/sensors/init.js +48 -7
  12. package/dist/src/commands/sensors/install.js +20 -11
  13. package/dist/src/commands/sensors/run.js +31 -29
  14. package/dist/src/commands/sync.js +29 -0
  15. package/dist/src/commands/watch/index.js +11 -1
  16. package/dist/src/core/artifact-name.js +80 -0
  17. package/dist/src/core/bundle-install.js +55 -12
  18. package/dist/src/core/bundles.js +72 -13
  19. package/dist/src/core/context/regenerate.js +21 -4
  20. package/dist/src/core/context/strategies/codex-agents.js +18 -2
  21. package/dist/src/core/diagnostics/checks.js +26 -2
  22. package/dist/src/core/diagnostics/context.js +91 -25
  23. package/dist/src/core/diagnostics/provider-checks.js +62 -14
  24. package/dist/src/core/discovery.js +2 -2
  25. package/dist/src/core/init/mutation-targets.js +34 -1
  26. package/dist/src/core/init/steps.js +70 -19
  27. package/dist/src/core/install-planner.js +62 -17
  28. package/dist/src/core/install-transaction.js +13 -1
  29. package/dist/src/core/journal/adapter.js +10 -1
  30. package/dist/src/core/paths.js +61 -8
  31. package/dist/src/core/profile.js +12 -0
  32. package/dist/src/core/provider-artifacts.js +38 -13
  33. package/dist/src/core/provider-version.js +12 -6
  34. package/dist/src/core/registries.js +40 -19
  35. package/dist/src/core/renderers/registry.js +37 -0
  36. package/dist/src/core/skill-integrity.js +71 -10
  37. package/dist/src/core/update-check.js +14 -2
  38. package/dist/src/core/versioning.js +17 -2
  39. package/dist/src/index.js +31 -23
  40. package/dist/src/providers/index.js +7 -2
  41. package/dist/src/utils/config.js +7 -8
  42. package/dist/tests/commands/doctor.test.js +6 -0
  43. package/dist/tests/commands/pin.test.js +29 -2
  44. package/dist/tests/commands/preflight/preflight.test.js +31 -17
  45. package/dist/tests/commands/registry/install-bundles.test.js +6 -6
  46. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +96 -0
  47. package/dist/tests/commands/sensors/install-settings-safety.test.js +87 -0
  48. package/dist/tests/commands/sensors/run-is-read-only.test.js +0 -0
  49. package/dist/tests/commands/sensors/run.test.js +24 -51
  50. package/dist/tests/commands/sensors/status-windows.test.js +39 -22
  51. package/dist/tests/commands/sensors/status.test.js +27 -12
  52. package/dist/tests/commands/watch/runner.test.js +10 -0
  53. package/dist/tests/core/artifact-name-containment.test.js +86 -0
  54. package/dist/tests/core/context/agents-md-single-slot.test.js +69 -0
  55. package/dist/tests/core/context/regenerate.test.js +39 -8
  56. package/dist/tests/core/diagnostics/checks.test.js +14 -6
  57. package/dist/tests/core/diagnostics/provider-tier.test.js +3 -3
  58. package/dist/tests/core/diagnostics/rendered-artifact-visibility.test.js +69 -0
  59. package/dist/tests/core/init/all-baseline-bundles.test.js +64 -0
  60. package/dist/tests/core/init/context-injection-no-project.test.js +117 -0
  61. package/dist/tests/core/init/mutation-targets-canonical.test.js +81 -0
  62. package/dist/tests/core/init/mutation-targets.test.js +4 -4
  63. package/dist/tests/core/init/orchestrator.test.js +1 -1
  64. package/dist/tests/core/init/steps-registry-sync.test.js +1 -1
  65. package/dist/tests/core/init/steps.test.js +11 -5
  66. package/dist/tests/core/path-resolution-no-shell.test.js +136 -0
  67. package/dist/tests/core/paths.test.js +7 -34
  68. package/dist/tests/core/project-skill-links.test.js +159 -0
  69. package/dist/tests/core/provider-artifacts.test.js +66 -14
  70. package/dist/tests/core/provider-capability-guards.test.js +92 -0
  71. package/dist/tests/core/reconciliation.test.js +15 -15
  72. package/dist/tests/core/registry-manifest.test.js +1 -1
  73. package/dist/tests/core/semver-fails-closed.test.js +30 -0
  74. package/dist/tests/core/skill-integrity.test.js +10 -10
  75. package/dist/tests/core/skill-repair-safety.test.js +92 -0
  76. package/dist/tests/core/sync-profile-atomicity.test.js +122 -0
  77. package/dist/tests/integration/copilot-init-isolated.test.js +2 -2
  78. package/dist/tests/providers/index.test.js +1 -1
  79. package/dist/tests/structural/renderer-table-is-single-source.test.js +95 -0
  80. package/dist/tests/structural/support-matrix-is-current.test.js +100 -0
  81. package/dist/tests/structural/symlink-type-is-explicit.test.js +73 -0
  82. package/dist/tests/support/tmp.js +31 -0
  83. package/dist/tests/utils/registry-view-overrides.test.js +2 -2
  84. package/package.json +6 -2
@@ -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',
@@ -0,0 +1,95 @@
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
+ // Structural guard, in the vein of exec-invocation-explicit-stdio.test.ts: it does not
7
+ // test behaviour, it forbids the SHAPE that produced a whole family of bugs.
8
+ //
9
+ // "What filename does renderer R produce" existed in four places. Three agreed; the
10
+ // fourth — the one deciding whether a rendered artifact counted as installed — had
11
+ // never heard of `.mdc` or `.instructions.md`, so `awm add` re-installed the same
12
+ // Cursor/Copilot artifacts on every run and `awm doctor` called a healthy install
13
+ // absent. Each instance was individually reasonable; the class is what cost a release.
14
+ //
15
+ // `core/renderers/registry.ts` is now the single table. This test fails the moment a
16
+ // second one appears, whether by a new literal map or by a fresh `switch` over renderer
17
+ // ids somewhere that should have asked the table.
18
+ const fs_1 = __importDefault(require("fs"));
19
+ const path_1 = __importDefault(require("path"));
20
+ const SRC = path_1.default.join(__dirname, '..', '..', 'src');
21
+ const REGISTRY = path_1.default.join(SRC, 'core', 'renderers', 'registry.ts');
22
+ /** Every .ts file under src/, recursively. */
23
+ function sourceFiles(dir) {
24
+ const out = [];
25
+ for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
26
+ const full = path_1.default.join(dir, entry.name);
27
+ if (entry.isDirectory())
28
+ out.push(...sourceFiles(full));
29
+ else if (entry.name.endsWith('.ts'))
30
+ out.push(full);
31
+ }
32
+ return out;
33
+ }
34
+ /** The file's lines with `//` comments and blank lines dropped — a renderer id named in
35
+ * prose (a doc comment explaining the bug) is not a second implementation of the table. */
36
+ function codeLines(file) {
37
+ const raw = fs_1.default.readFileSync(file, 'utf-8').split('\n');
38
+ const out = [];
39
+ let inBlockComment = false;
40
+ raw.forEach((line, i) => {
41
+ let l = line;
42
+ if (inBlockComment) {
43
+ const end = l.indexOf('*/');
44
+ if (end === -1)
45
+ return;
46
+ inBlockComment = false;
47
+ l = l.slice(end + 2);
48
+ }
49
+ const start = l.indexOf('/*');
50
+ if (start !== -1) {
51
+ inBlockComment = l.indexOf('*/', start) === -1;
52
+ l = l.slice(0, start) + (inBlockComment ? '' : l.slice(l.indexOf('*/', start) + 2));
53
+ }
54
+ const lineComment = l.indexOf('//');
55
+ if (lineComment !== -1)
56
+ l = l.slice(0, lineComment);
57
+ if (l.trim())
58
+ out.push({ line: l, n: i + 1 });
59
+ });
60
+ return out;
61
+ }
62
+ /** The extensions the renderer table owns. A literal here, outside the table, IS the bug. */
63
+ const RENDERED_EXTENSIONS = ['.toml', '.mdc', '.instructions.md'];
64
+ describe('the renderer table is the only renderer table', () => {
65
+ const files = sourceFiles(SRC).filter((f) => f !== REGISTRY);
66
+ it('no other source file maps a renderer id to a file extension', () => {
67
+ const offenders = [];
68
+ for (const file of files) {
69
+ for (const { line, n } of codeLines(file)) {
70
+ // A renderer-to-extension mapping looks like an id and an extension on
71
+ // the same line of code. That is exactly the shape of every copy that
72
+ // drifted, and nothing legitimate needs to write both at once now that
73
+ // `rendererExtension`/`renderedFilename` exist.
74
+ const namesRenderer = /'(codex-agent-toml|cursor-mdc|copilot-instructions)'/.test(line);
75
+ const namesExtension = RENDERED_EXTENSIONS.some((e) => line.includes(`'${e}'`) || line.includes(`"${e}"`));
76
+ if (namesRenderer && namesExtension) {
77
+ offenders.push(`${path_1.default.relative(SRC, file)}:${n}: ${line.trim()}`);
78
+ }
79
+ }
80
+ }
81
+ expect(offenders).toEqual([]);
82
+ });
83
+ it('the table itself still covers every declared renderer', () => {
84
+ // The guard above is only worth anything if the one surviving table is complete:
85
+ // a renderer added to `RendererId` but not to the table would make the guard
86
+ // push every caller toward a lookup that has no answer.
87
+ const { RENDERER_IDS } = require('../../src/providers');
88
+ const { rendererExtension } = require('../../src/core/renderers/registry');
89
+ for (const id of RENDERER_IDS) {
90
+ // `null` (the `link` renderer, which keeps the name as-is) is an ANSWER;
91
+ // `undefined` is the table not knowing about the renderer at all.
92
+ expect(rendererExtension(id)).not.toBeUndefined();
93
+ }
94
+ });
95
+ });
@@ -0,0 +1,100 @@
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
+ // Guarda estructural: `docs/support-matrix.md` no puede desalinearse de
7
+ // `src/providers/index.ts`.
8
+ //
9
+ // La tabla escrita a mano ya mintio. Decia que Antigravity instalaba en
10
+ // `~/.agents/skills` y `.agents/skills`; el codigo dice `~/.gemini/antigravity/skills` y
11
+ // `.agent/skills` — singular — y ademas es el unico provider con `global_workflows`, cosa
12
+ // que la tabla ni mencionaba. Nadie lo noto porque nada obligaba a mirarlo: la tabla se
13
+ // escribio una vez, el codigo siguio cambiando, y el documento se cita en presentaciones y
14
+ // se planifica encima.
15
+ //
16
+ // Este test hace que ese desvio sea imposible de mergear. No verifica que la tabla sea
17
+ // "linda": verifica que sea LA MISMA que produce el codigo hoy.
18
+ const fs_1 = __importDefault(require("fs"));
19
+ const support_matrix_1 = require("../../scripts/support-matrix");
20
+ describe('docs/support-matrix.md refleja el codigo', () => {
21
+ it('el bloque generado esta al dia', () => {
22
+ const doc = fs_1.default.readFileSync(support_matrix_1.DOC_PATH, 'utf-8');
23
+ const expected = (0, support_matrix_1.spliceGenerated)(doc, (0, support_matrix_1.renderProviderTables)());
24
+ // Mensaje accionable: el que rompe esto casi siempre acaba de tocar
25
+ // providers/index.ts y no sabe que este documento existe.
26
+ expect(doc).toBe(expected);
27
+ });
28
+ it('el documento conserva sus marcadores', () => {
29
+ const doc = fs_1.default.readFileSync(support_matrix_1.DOC_PATH, 'utf-8');
30
+ expect(doc).toContain(support_matrix_1.BEGIN_MARKER);
31
+ expect(doc).toContain(support_matrix_1.END_MARKER);
32
+ });
33
+ it('la tabla nombra a los seis providers declarados', () => {
34
+ // Que el bloque este "al dia" no sirve si el generador se olvidara de un provider:
35
+ // doc y generador coincidirian, los dos incompletos.
36
+ const { AGENT_TARGETS } = require('../../src/providers');
37
+ const generated = (0, support_matrix_1.renderProviderTables)();
38
+ for (const agent of AGENT_TARGETS) {
39
+ expect(generated).toContain(`\`${agent}\``);
40
+ }
41
+ });
42
+ it('abrevia el home y usa separadores POSIX, para no depender de la maquina', () => {
43
+ // Con un HOME inventado: si la tabla dependiera de la maquina que la genera, este
44
+ // home apareceria literal en la salida y el doc comiteado cambiaria segun quien
45
+ // corriera el generador — el test de arriba se volveria ruido permanente.
46
+ const saved = process.env.HOME;
47
+ process.env.HOME = '/home/inventado';
48
+ try {
49
+ jest.resetModules();
50
+ const { renderProviderTables: fresh } = require('../../scripts/support-matrix');
51
+ const generated = fresh();
52
+ expect(generated).toContain('`~/.claude/skills`');
53
+ expect(generated).not.toContain('/home/inventado');
54
+ expect(generated).not.toContain('\\');
55
+ }
56
+ finally {
57
+ if (saved === undefined)
58
+ delete process.env.HOME;
59
+ else
60
+ process.env.HOME = saved;
61
+ jest.resetModules();
62
+ }
63
+ });
64
+ // La abreviacion a `~` se probaba solo end-to-end, y en Linux eso no distingue entre
65
+ // "normaliza bien" y "los separadores ya coincidian". La CI de Windows encontro que no
66
+ // normalizaba: comparaba el prefijo ANTES de convertir separadores, asi que alla no
67
+ // abreviaba nada y la tabla regenerada no era la comiteada. Probar la unidad con las
68
+ // dos formas de ruta lo detecta en cualquier sistema, sin fingir la plataforma.
69
+ describe('homeRelative normaliza antes de comparar', () => {
70
+ it.each([
71
+ ['posix', '/home/x', '/home/x/.claude/skills', '~/.claude/skills'],
72
+ ['win32', 'C:\\Users\\x', 'C:\\Users\\x\\.claude\\skills', '~/.claude/skills'],
73
+ ['mixto', '/home/x', '\\home\\x\\.claude\\skills', '~/.claude/skills'],
74
+ ['sin prefijo', '/home/x', '/opt/otro/skills', '/opt/otro/skills'],
75
+ ])('%s', (_n, home, input, expected) => {
76
+ expect((0, support_matrix_1.homeRelative)(input, home)).toBe(expected);
77
+ });
78
+ it('nunca deja un separador de Windows en la salida', () => {
79
+ expect((0, support_matrix_1.homeRelative)('C:\\Users\\x\\.codex\\agents', 'C:\\Users\\x')).not.toContain('\\');
80
+ });
81
+ });
82
+ it('respeta el fin de linea del documento (un checkout de Windows entrega CRLF)', () => {
83
+ // Sin esto, regenerar en Windows dejaba el archivo con finales mezclados y el test
84
+ // de arriba fallaba por bytes ajenos al contenido. Es el mismo error de fondo que
85
+ // el de los separadores: asumir la forma de POSIX para un dato que la plataforma
86
+ // decide.
87
+ const crlfDoc = `intro\r\n${support_matrix_1.BEGIN_MARKER}\r\nviejo\r\n${support_matrix_1.END_MARKER}\r\nfin\r\n`;
88
+ const out = (0, support_matrix_1.spliceGenerated)(crlfDoc, 'linea uno\nlinea dos');
89
+ expect(out).toContain('linea uno\r\nlinea dos');
90
+ expect(out.split('\r\n').length - 1).toBe(out.split('\n').length - 1); // ni un LF suelto
91
+ });
92
+ it('marca como no soportado, no como ausente, el scope que un provider no tiene', () => {
93
+ // La diferencia entre "no soportado" y una celda vacia es exactamente lo que el
94
+ // documento existe para no dejar ambiguo: Copilot no tiene scope global por
95
+ // decision del producto que integramos, no porque falte implementarlo.
96
+ const generated = (0, support_matrix_1.renderProviderTables)();
97
+ const copilotRow = generated.split('\n').find((l) => l.startsWith('| `copilot` |'));
98
+ expect(copilotRow).toContain('**no soportado**');
99
+ });
100
+ });
@@ -0,0 +1,73 @@
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
+ // Structural guard, same family as exec-invocation-explicit-stdio.test.ts.
7
+ //
8
+ // `fs.symlinkSync(target, dest)` with no third argument creates a DIRECTORY symlink on
9
+ // Windows when the target is a directory, and that needs SeCreateSymbolicLinkPrivilege
10
+ // — denied by default on unprivileged accounts, including GitHub Actions'
11
+ // windows-latest runner. The same omission has been fixed one site at a time, more
12
+ // than once, each fix landing where the bug was reported and nowhere else:
13
+ // `executor.ts` learned `'junction'`, and `skill-integrity.ts` did not, for a whole
14
+ // release — its comment still says "este sitio nunca recibio el fix".
15
+ //
16
+ // So the rule is about the CALL, not about any one call site: every `fs.symlinkSync`
17
+ // either passes an explicit type argument, or is wrapped in a `try` whose `catch`
18
+ // falls back to a copy. Those are the only two ways this survives Windows, and one of
19
+ // them has to be visible at the call.
20
+ const fs_1 = __importDefault(require("fs"));
21
+ const path_1 = __importDefault(require("path"));
22
+ const SRC = path_1.default.join(__dirname, '..', '..', 'src');
23
+ function sourceFiles(dir) {
24
+ const out = [];
25
+ for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
26
+ const full = path_1.default.join(dir, entry.name);
27
+ if (entry.isDirectory())
28
+ out.push(...sourceFiles(full));
29
+ else if (entry.name.endsWith('.ts'))
30
+ out.push(full);
31
+ }
32
+ return out;
33
+ }
34
+ /** True when `lines[i]` sits inside a try block whose catch copies instead. Deliberately
35
+ * shallow — it looks a few lines ahead for the catch, which is what every real fallback
36
+ * in this codebase looks like. A cleverer arrangement that defeats it should be written
37
+ * with an explicit type argument instead. */
38
+ function hasCopyFallback(lines, i) {
39
+ const window = lines.slice(Math.max(0, i - 3), i + 8).join('\n');
40
+ return /\btry\b/.test(window) && /catch\b/.test(window) && /copyFileSync|cpSync/.test(window);
41
+ }
42
+ describe('every symlink creation survives Windows on purpose', () => {
43
+ it('passes an explicit type argument or falls back to a copy', () => {
44
+ const offenders = [];
45
+ for (const file of sourceFiles(SRC)) {
46
+ const lines = fs_1.default.readFileSync(file, 'utf-8').split('\n');
47
+ lines.forEach((line, i) => {
48
+ if (!/\bfs\.symlinkSync\s*\(/.test(line))
49
+ return;
50
+ // Three arguments — the third is the type ('junction' | 'dir' | 'file').
51
+ const explicitType = /symlinkSync\s*\([^)]*,[^)]*,\s*[^)]+\)/.test(line) ||
52
+ /'junction'|'dir'|'file'/.test(lines.slice(i, i + 3).join('\n'));
53
+ if (explicitType || hasCopyFallback(lines, i))
54
+ return;
55
+ offenders.push(`${path_1.default.relative(SRC, file)}:${i + 1}: ${line.trim()}`);
56
+ });
57
+ }
58
+ expect(offenders).toEqual([]);
59
+ });
60
+ it('the guard actually rejects a bare two-argument call', () => {
61
+ // Without this, a guard whose regex silently stopped matching would read as a
62
+ // clean bill of health forever — the failure mode of every structural test.
63
+ const bare = ['fs.symlinkSync(source, dest);'];
64
+ const explicit = ["fs.symlinkSync(source, dest, 'junction');"];
65
+ const wrapped = ['try {', 'fs.symlinkSync(source, dest);', '} catch {', 'fs.copyFileSync(source, dest);', '}'];
66
+ const rejects = (lines, i) => !/symlinkSync\s*\([^)]*,[^)]*,\s*[^)]+\)/.test(lines[i]) &&
67
+ !/'junction'|'dir'|'file'/.test(lines.slice(i, i + 3).join('\n')) &&
68
+ !hasCopyFallback(lines, i);
69
+ expect(rejects(bare, 0)).toBe(true);
70
+ expect(rejects(explicit, 0)).toBe(false);
71
+ expect(rejects(wrapped, 1)).toBe(false);
72
+ });
73
+ });
@@ -0,0 +1,31 @@
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
+ exports.mkCanonicalTmpDir = mkCanonicalTmpDir;
7
+ // Directorios temporales en forma canonica, para tests que comparan rutas.
8
+ //
9
+ // En macOS `os.tmpdir()` devuelve `/var/folders/…`, que es un SYMLINK a
10
+ // `/private/var/folders/…`. El producto resuelve el root del proyecto con `realpathSync`
11
+ // (`findProjectRoot`, `core/profile.ts`), asi que devuelve la forma canonica. Un test que
12
+ // arma su expectativa con el `mkdtemp` crudo compara `/var/…` contra `/private/var/…` y
13
+ // falla sobre un producto que se comporta bien. En Linux y Windows las dos formas
14
+ // coinciden, asi que el problema es invisible hasta que corre en macOS — paso tres veces
15
+ // en la primera corrida de macOS en CI.
16
+ //
17
+ // Usar esto en cualquier test que:
18
+ // - compare una ruta contra la que devuelve el producto, o
19
+ // - le pase el tmpdir al producto y despues afirme sobre lo que salio.
20
+ //
21
+ // Un test que solo escribe y lee archivos bajo su tmpdir no lo necesita: las dos formas
22
+ // llegan al mismo inodo. Por eso esto es un helper y no una migracion masiva — envolver
23
+ // los 113 archivos que crean tmpdirs seria ruido, no rigor.
24
+ const fs_1 = __importDefault(require("fs"));
25
+ const os_1 = __importDefault(require("os"));
26
+ const path_1 = __importDefault(require("path"));
27
+ /** `fs.mkdtempSync` + `realpathSync`: el mismo directorio, en la forma que el producto
28
+ * va a usar. El `realpathSync` no puede fallar — el directorio acaba de crearse. */
29
+ function mkCanonicalTmpDir(prefix) {
30
+ return fs_1.default.realpathSync(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), prefix)));
31
+ }
@@ -25,9 +25,9 @@ describe('registry-view override markers', () => {
25
25
  fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
26
26
  });
27
27
  it('packageDetailLines marks an overridden skill with its registry name', () => {
28
- const { REGISTRIES_DIR } = require('../../src/core/registries');
28
+ const { registriesDir } = require('../../src/core/registries');
29
29
  const { buildPackageView, packageDetailLines } = require('../../src/utils/registry-view');
30
- const teamSkillPath = path_1.default.join(REGISTRIES_DIR, 'team-acme', 'skills', 'brainstorming');
30
+ const teamSkillPath = path_1.default.join(registriesDir(), 'team-acme', 'skills', 'brainstorming');
31
31
  const view = buildPackageView([{ name: 'brainstorming', path: teamSkillPath, description: 'd', overrode: '/old/path' }], [], [], []);
32
32
  const lines = packageDetailLines(view[0]).join('\n');
33
33
  expect(lines).toContain('brainstorming');